diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_app.js b/src/core_plugins/kibana/public/dashboard/dashboard_app.js index 9f65e52997440f..ce6ac9e7d48e47 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard_app.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_app.js @@ -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(), }); }; diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_state_manager.js b/src/core_plugins/kibana/public/dashboard/dashboard_state_manager.js index 4f208a63656b63..3b7c759967ec09 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard_state_manager.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_state_manager.js @@ -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() { diff --git a/src/core_plugins/kibana/public/discover/controllers/discover.js b/src/core_plugins/kibana/public/discover/controllers/discover.js index fee5c13ff5304a..434e26b8c49348 100644 --- a/src/core_plugins/kibana/public/discover/controllers/discover.js +++ b/src/core_plugins/kibana/public/discover/controllers/discover.js @@ -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() { diff --git a/src/core_plugins/kibana/public/kibana.js b/src/core_plugins/kibana/public/kibana.js index 5ebd66b5492bc7..7daafbf6411a94 100644 --- a/src/core_plugins/kibana/public/kibana.js +++ b/src/core_plugins/kibana/public/kibana.js @@ -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'; diff --git a/src/core_plugins/kibana/public/visualize/editor/editor.js b/src/core_plugins/kibana/public/visualize/editor/editor.js index 42017d2c4da039..07eef274f47956 100644 --- a/src/core_plugins/kibana/public/visualize/editor/editor.js +++ b/src/core_plugins/kibana/public/visualize/editor/editor.js @@ -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); } diff --git a/src/ui/public/chrome/index.d.ts b/src/ui/public/chrome/index.d.ts index caadd5f09828bb..6b7835a26f90b1 100644 --- a/src/ui/public/chrome/index.d.ts +++ b/src/ui/public/chrome/index.d.ts @@ -27,6 +27,7 @@ declare class Chrome { public getBasePath(): string; public getXsrfToken(): string; public getKibanaVersion(): string; + public getUiSettingsClient(): any; } declare const chrome: Chrome; diff --git a/src/ui/public/share/components/__snapshots__/share_context_menu.test.js.snap b/src/ui/public/share/components/__snapshots__/share_context_menu.test.js.snap index 5708a29a93db48..815661b15213b3 100644 --- a/src/ui/public/share/components/__snapshots__/share_context_menu.test.js.snap +++ b/src/ui/public/share/components/__snapshots__/share_context_menu.test.js.snap @@ -2,11 +2,12 @@ exports[`should only render permalink panel when there are no other panels 1`] = ` , @@ -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`] = ` , @@ -32,7 +34,7 @@ exports[`should render context menu panel when there are more than one panel 1`] "title": "Permalink", }, Object { - "content": @@ -135,7 +136,7 @@ exports[`render 1`] = ` exports[`should enable saved object export option when objectId is provided 1`] = ` diff --git a/src/ui/public/share/components/share_context_menu.tsx b/src/ui/public/share/components/share_context_menu.tsx index 6846632ba63dad..74b221509b6ff5 100644 --- a/src/ui/public/share/components/share_context_menu.tsx +++ b/src/ui/public/share/components/share_context_menu.tsx @@ -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 { public render() { const { panels, initialPanelId } = this.getPanels(); - return ; + return ( + + ); } private getPanels = () => { - const panels = []; - const menuItems = []; + const panels: EuiContextMenuPanelDescriptor[] = []; + const menuItems: EuiContextMenuPanelItemDescriptor[] = []; const permalinkPanel = { id: panels.length + 1, title: 'Permalink', content: ( - { id: panels.length + 1, title: 'Embed Code', content: ( - { }); } - // 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); } diff --git a/src/ui/public/share/components/share_panel_content.less b/src/ui/public/share/components/share_panel_content.less new file mode 100644 index 00000000000000..23ffd1015d14ce --- /dev/null +++ b/src/ui/public/share/components/share_panel_content.less @@ -0,0 +1,11 @@ +.sharePanelContent{ + padding: 16px; +} + +.sharePanel__copyAnchor { + width: 100%; +} + +.sharePanel__button { + width: 100%; +} diff --git a/src/ui/public/share/components/share_url_content.less b/src/ui/public/share/components/share_url_content.less deleted file mode 100644 index 95b950e5b0e940..00000000000000 --- a/src/ui/public/share/components/share_url_content.less +++ /dev/null @@ -1,3 +0,0 @@ -.shareUrlContentForm{ - padding: 16px; -} diff --git a/src/ui/public/share/components/share_url_content.test.js b/src/ui/public/share/components/url_panel_content.test.js similarity index 90% rename from src/ui/public/share/components/share_url_content.test.js rename to src/ui/public/share/components/url_panel_content.test.js index 3ee722041eca44..025c282c83bc89 100644 --- a/src/ui/public/share/components/share_url_content.test.js +++ b/src/ui/public/share/components/url_panel_content.test.js @@ -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( {}} />); @@ -35,7 +35,7 @@ test('render', () => { }); test('should enable saved object export option when objectId is provided', () => { - const component = shallow( {}} diff --git a/src/ui/public/share/components/share_url_content.tsx b/src/ui/public/share/components/url_panel_content.tsx similarity index 93% rename from src/ui/public/share/components/share_url_content.tsx rename to src/ui/public/share/components/url_panel_content.tsx index 4c3f1812dff4a0..932ea0887ccac3 100644 --- a/src/ui/public/share/components/share_url_content.tsx +++ b/src/ui/public/share/components/url_panel_content.tsx @@ -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 { +export class UrlPanelContent extends Component { private mounted?: boolean; private shortUrlCache?: string; @@ -99,22 +98,25 @@ export class ShareUrlContent extends Component { public render() { return ( - + {this.renderExportAsRadioGroup()} {this.renderShortUrlSwitch()} - + {(copy: () => void) => ( - - Copy {this.props.isEmbedded ? 'iFrame code' : 'link'} - + + + Copy {this.props.isEmbedded ? 'iFrame code' : 'link'} + + )} diff --git a/src/ui/public/share/index.js b/src/ui/public/share/index.js index 99728720d526b3..3a1264541cdea6 100644 --- a/src/ui/public/share/index.js +++ b/src/ui/public/share/index.js @@ -18,3 +18,4 @@ */ export { showShareContextMenu } from './show_share_context_menu'; +export { ShareContextMenuExtensionsRegistryProvider } from './share_action_registry'; diff --git a/src/ui/public/share/share_action.ts b/src/ui/public/share/share_action.ts new file mode 100644 index 00000000000000..abd5c56d57770b --- /dev/null +++ b/src/ui/public/share/share_action.ts @@ -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[]; +} diff --git a/src/ui/public/share/share_action_registry.ts b/src/ui/public/share/share_action_registry.ts new file mode 100644 index 00000000000000..b6f828bbf56f96 --- /dev/null +++ b/src/ui/public/share/share_action_registry.ts @@ -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'], +}); diff --git a/src/ui/public/share/show_share_context_menu.tsx b/src/ui/public/share/show_share_context_menu.tsx index 4adefcad539ea0..58a103015a1a9c 100644 --- a/src/ui/public/share/show_share_context_menu.tsx +++ b/src/ui/public/share/show_share_context_menu.tsx @@ -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} /> ); diff --git a/src/ui/public/utils/query_string.d.ts b/src/ui/public/utils/query_string.d.ts new file mode 100644 index 00000000000000..3f3c1752d38b29 --- /dev/null +++ b/src/ui/public/utils/query_string.d.ts @@ -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 }; diff --git a/src/ui/ui_exports/ui_export_types/index.js b/src/ui/ui_exports/ui_export_types/index.js index b9122d7735bdbb..d6ebaa649adf8f 100644 --- a/src/ui/ui_exports/ui_export_types/index.js +++ b/src/ui/ui_exports/ui_export_types/index.js @@ -54,6 +54,7 @@ export { visualize, search, autocompleteProviders, + shareContextMenuExtensions, } from './ui_app_extensions'; export { diff --git a/src/ui/ui_exports/ui_export_types/ui_app_extensions.js b/src/ui/ui_exports/ui_export_types/ui_app_extensions.js index 66b6f43b3497a4..d2d91efb4be832 100644 --- a/src/ui/ui_exports/ui_export_types/ui_app_extensions.js +++ b/src/ui/ui_exports/ui_export_types/ui_app_extensions.js @@ -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; diff --git a/test/functional/page_objects/share_page.js b/test/functional/page_objects/share_page.js index 07951ebf2cae58..b94d5c2de3ee44 100644 --- a/test/functional/page_objects/share_page.js +++ b/test/functional/page_objects/share_page.js @@ -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'); } diff --git a/x-pack/package.json b/x-pack/package.json index c4f520240351e0..df9969e1256718 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -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", diff --git a/x-pack/plugins/dashboard_mode/public/dashboard_viewer.js b/x-pack/plugins/dashboard_mode/public/dashboard_viewer.js index 4cdae56cd0b773..454afaf884a785 100644 --- a/x-pack/plugins/dashboard_mode/public/dashboard_viewer.js +++ b/x-pack/plugins/dashboard_mode/public/dashboard_viewer.js @@ -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'; diff --git a/x-pack/plugins/reporting/export_types/csv/public/index.js b/x-pack/plugins/reporting/export_types/csv/public/index.js deleted file mode 100644 index 1fd1483e658fc3..00000000000000 --- a/x-pack/plugins/reporting/export_types/csv/public/index.js +++ /dev/null @@ -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 - }); -} diff --git a/x-pack/plugins/reporting/export_types/csv/public/job_params_provider.js b/x-pack/plugins/reporting/export_types/csv/public/job_params_provider.js deleted file mode 100644 index ea626a14dc4961..00000000000000 --- a/x-pack/plugins/reporting/export_types/csv/public/job_params_provider.js +++ /dev/null @@ -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 - }; - }; -} diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/public/index.js b/x-pack/plugins/reporting/export_types/printable_pdf/public/index.js deleted file mode 100644 index 823e59cc1ffe77..00000000000000 --- a/x-pack/plugins/reporting/export_types/printable_pdf/public/index.js +++ /dev/null @@ -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: `` - }); -} diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/public/job_params_provider.js b/x-pack/plugins/reporting/export_types/printable_pdf/public/job_params_provider.js deleted file mode 100644 index 4b8d9064b5a5c0..00000000000000 --- a/x-pack/plugins/reporting/export_types/printable_pdf/public/job_params_provider.js +++ /dev/null @@ -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(), - }; - }; -} diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/public/layouts/index.js b/x-pack/plugins/reporting/export_types/printable_pdf/public/layouts/index.js deleted file mode 100644 index 5a32d086b07237..00000000000000 --- a/x-pack/plugins/reporting/export_types/printable_pdf/public/layouts/index.js +++ /dev/null @@ -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}`); - } -} \ No newline at end of file diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/public/layouts/preserve_layout.js b/x-pack/plugins/reporting/export_types/printable_pdf/public/layouts/preserve_layout.js deleted file mode 100644 index a8bb3e3c8c5dc6..00000000000000 --- a/x-pack/plugins/reporting/export_types/printable_pdf/public/layouts/preserve_layout.js +++ /dev/null @@ -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, - } - }; - } -}; diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/public/options.html b/x-pack/plugins/reporting/export_types/printable_pdf/public/options.html deleted file mode 100644 index 7c50941f1cd17c..00000000000000 --- a/x-pack/plugins/reporting/export_types/printable_pdf/public/options.html +++ /dev/null @@ -1,10 +0,0 @@ -
- - -
diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/public/options.js b/x-pack/plugins/reporting/export_types/printable_pdf/public/options.js deleted file mode 100644 index 804185ca0b508d..00000000000000 --- a/x-pack/plugins/reporting/export_types/printable_pdf/public/options.js +++ /dev/null @@ -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'; - } - } - }; -}); diff --git a/x-pack/plugins/reporting/index.js b/x-pack/plugins/reporting/index.js index fdf4948fb408d0..ee1514e9bf9246 100644 --- a/x-pack/plugins/reporting/index.js +++ b/x-pack/plugins/reporting/index.js @@ -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'], diff --git a/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx b/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx new file mode 100644 index 00000000000000..7aa49e0e6680f8 --- /dev/null +++ b/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx @@ -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; + export const EuiForm: React.SFC; +} + +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; + isDirty: boolean; + onClose: () => void; +} + +interface State { + isStale: boolean; + absoluteUrl: string; +} + +export class ReportingPanelContent extends Component { + 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 ( + + + {this.renderGenerateReportButton(true)} + + + ); + } + + const reportMsg = `${this.prettyPrintReportingType()}s can take a minute or two to generate based upon the size of your ${ + this.props.objectType + }.`; + + return ( + + +

{reportMsg}

+
+ + + {this.props.options} + + {this.renderGenerateReportButton(false)} + + + +

+ Alternatively, copy this POST URL to call generation from outside Kibana or from + Watcher. +

+
+ + + + {(copy: () => void) => ( + + Copy POST URL + + )} + +
+ ); + } + + private renderGenerateReportButton = (isDisabled: boolean) => { + return ( + + Generate {this.prettyPrintReportingType()} + + ); + }; + + 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', + }); + }); + }; +} diff --git a/x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx b/x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx new file mode 100644 index 00000000000000..c18f1bbbc5160a --- /dev/null +++ b/x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx @@ -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 { + constructor(props: Props) { + super(props); + + this.state = { + usePrintLayout: false, + }; + } + + public render() { + return ( + + ); + } + + private renderOptions = () => { + return ( + + + + + ); + }; + + 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; + }; +} diff --git a/x-pack/plugins/reporting/public/controls/dashboard.js b/x-pack/plugins/reporting/public/controls/dashboard.js deleted file mode 100644 index ea0d9adfaffb48..00000000000000 --- a/x-pack/plugins/reporting/public/controls/dashboard.js +++ /dev/null @@ -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: ``, - 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); diff --git a/x-pack/plugins/reporting/public/controls/discover.js b/x-pack/plugins/reporting/public/controls/discover.js deleted file mode 100644 index 605474278e4c7e..00000000000000 --- a/x-pack/plugins/reporting/public/controls/discover.js +++ /dev/null @@ -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: '', - 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); diff --git a/x-pack/plugins/reporting/public/controls/visualize.js b/x-pack/plugins/reporting/public/controls/visualize.js deleted file mode 100644 index a5dfde62d23653..00000000000000 --- a/x-pack/plugins/reporting/public/controls/visualize.js +++ /dev/null @@ -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: ` - `, - 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); diff --git a/x-pack/plugins/reporting/public/directives/export_config/export_config.html b/x-pack/plugins/reporting/public/directives/export_config/export_config.html deleted file mode 100644 index c02d0225586b0c..00000000000000 --- a/x-pack/plugins/reporting/public/directives/export_config/export_config.html +++ /dev/null @@ -1,55 +0,0 @@ -
-
-

- Reporting -

- -
-
- -
-
- -
- - -
- - -
- - - -
-
- -
- Please save your work before generating a report. -
diff --git a/x-pack/plugins/reporting/public/directives/export_config/export_config.js b/x-pack/plugins/reporting/public/directives/export_config/export_config.js deleted file mode 100644 index ea15487893bcc3..00000000000000 --- a/x-pack/plugins/reporting/public/directives/export_config/export_config.js +++ /dev/null @@ -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'); - } - }); - }); - }; - } - }; -}); diff --git a/x-pack/plugins/reporting/public/directives/export_config/export_config.less b/x-pack/plugins/reporting/public/directives/export_config/export_config.less deleted file mode 100644 index d304ca9fb01500..00000000000000 --- a/x-pack/plugins/reporting/public/directives/export_config/export_config.less +++ /dev/null @@ -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; - } - } -} diff --git a/x-pack/plugins/reporting/public/directives/export_config/index.js b/x-pack/plugins/reporting/public/directives/export_config/index.js deleted file mode 100644 index 06c257934cacdc..00000000000000 --- a/x-pack/plugins/reporting/public/directives/export_config/index.js +++ /dev/null @@ -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'; diff --git a/x-pack/plugins/reporting/public/lib/reporting_client.ts b/x-pack/plugins/reporting/public/lib/reporting_client.ts new file mode 100644 index 00000000000000..80af64706cba06 --- /dev/null +++ b/x-pack/plugins/reporting/public/lib/reporting_client.ts @@ -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(); diff --git a/x-pack/plugins/reporting/public/services/document_control.js b/x-pack/plugins/reporting/public/services/document_control.js deleted file mode 100644 index 74086fdc0dd6eb..00000000000000 --- a/x-pack/plugins/reporting/public/services/document_control.js +++ /dev/null @@ -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; - }); - }; - }); diff --git a/x-pack/plugins/reporting/public/services/export_types.js b/x-pack/plugins/reporting/public/services/export_types.js deleted file mode 100644 index 09c78296ba0139..00000000000000 --- a/x-pack/plugins/reporting/public/services/export_types.js +++ /dev/null @@ -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); - }; - }); diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/public/layouts/print.js b/x-pack/plugins/reporting/public/services/job_completion_notifications.d.ts similarity index 56% rename from x-pack/plugins/reporting/export_types/printable_pdf/public/layouts/print.js rename to x-pack/plugins/reporting/public/services/job_completion_notifications.d.ts index 64ba1035747848..3eacc3046e15a1 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/public/layouts/print.js +++ b/x-pack/plugins/reporting/public/services/job_completion_notifications.d.ts @@ -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 }; diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx new file mode 100644 index 00000000000000..15d9d1d86fc346 --- /dev/null +++ b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx @@ -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: ( + + ), + }, + }); + } + + return shareActions; + }; + + return { + id: 'csvReports', + getShareActions, + }; +} + +ShareContextMenuExtensionsRegistryProvider.register(reportingProvider); diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_reporting.tsx new file mode 100644 index 00000000000000..de4b5500316da4 --- /dev/null +++ b/x-pack/plugins/reporting/public/share_context_menu/register_reporting.tsx @@ -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: ( + + ), + }, + }); + } + + // TODO register PNG menu item once PNG is supported on server side + + return shareActions; + }; + + return { + id: 'screenCaptureReports', + getShareActions, + }; +} + +ShareContextMenuExtensionsRegistryProvider.register(reportingProvider); diff --git a/x-pack/test/functional/apps/security/secure_roles_perm.js b/x-pack/test/functional/apps/security/secure_roles_perm.js index 5a693e74b9327a..97f6b35f258714 100644 --- a/x-pack/test/functional/apps/security/secure_roles_perm.js +++ b/x-pack/test/functional/apps/security/secure_roles_perm.js @@ -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); diff --git a/x-pack/test/functional/page_objects/reporting_page.js b/x-pack/test/functional/page_objects/reporting_page.js index 4108668605f1e5..eab5d0603c73ec 100644 --- a/x-pack/test/functional/page_objects/reporting_page.js +++ b/x-pack/test/functional/page_objects/reporting_page.js @@ -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(); - - if (!isOpen) { - await this.clickTopNavReportingLink(); - } + async openCsvReportingPanel() { + log.debug('openCsvReportingPanel'); + await PageObjects.share.openShareMenuItem('CSV Reports'); + } - 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() { diff --git a/x-pack/test/reporting/functional/reporting.js b/x-pack/test/reporting/functional/reporting.js index 3621b1c518dd3d..515347c45965c0 100644 --- a/x-pack/test/reporting/functional/reporting.js +++ b/x-pack/test/reporting/functional/reporting.js @@ -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); diff --git a/x-pack/test/reporting/functional/reports/baseline/visualize_print.pdf b/x-pack/test/reporting/functional/reports/baseline/visualize_print.pdf index e42145c9fb293f..0337757ecb5f55 100644 Binary files a/x-pack/test/reporting/functional/reports/baseline/visualize_print.pdf and b/x-pack/test/reporting/functional/reports/baseline/visualize_print.pdf differ diff --git a/x-pack/yarn.lock b/x-pack/yarn.lock index d2f0b8def23acc..ce6e22c94efb93 100644 --- a/x-pack/yarn.lock +++ b/x-pack/yarn.lock @@ -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" diff --git a/yarn.lock b/yarn.lock index ec3fa7aa6be4ae..b5090364fbb0d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"