Eui sharing top nav (#21997) (#22468)

* just getting the popover to open and start laying out the context menu

* pass getUnhashableStates to ShareMenu

* generate original and snapshot ids

* move state into ShareUrlContent

* start working on form

* use radio group

* add input for creating short URL

* display URL in alert until copy functionallity gets migrated to EUI

* allowEmbed prop

* replace share directive with showShareContextMenu

* fix button styling

* add jest test for share_context_menu

* use EuiCopy to copy URL, add jest test for ShareUrlContent component

* clean up

* display short URL create error message in form instead of with toast

* switch option order so disbaled option can not be first

* fix discover share functional tests

* add functions required by reporting

* typescript

* remove empty file

* fix typescript compile error

* move import so jest tests work

* fix Failed prop type: The proptextToCopyis marked as required inEuiCopy, but its value isundefined

* move shortUrl out of react state and into Component object

* getUnhashableStates type from any[] to object[]

* add comment about type change once EUI issue is solved

* add functional test for saved object URL sharing

* remove commit
This commit is contained in:
Nathan Reese 2018-08-28 14:52:16 -06:00 committed by GitHub
parent 48fdc0f51e
commit 27dd8c2919
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1118 additions and 557 deletions

View file

@ -43,6 +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 { migrateLegacyQuery } from 'ui/utils/migrateLegacyQuery';
import * as filterActions from 'ui/doc_table/actions/filter';
import { FilterManagerProvider } from 'ui/filter_manager';
@ -50,6 +51,7 @@ import { EmbeddableFactoriesRegistryProvider } from 'ui/embeddable/embeddable_fa
import { DashboardPanelActionsRegistryProvider } from 'ui/dashboard_panel_actions/dashboard_panel_actions_registry';
import { VisTypesRegistryProvider } from 'ui/registry/vis_types';
import { timefilter } from 'ui/timefilter';
import { getUnhashableStatesProvider } from 'ui/state_management/state_hashing';
import { DashboardViewportProvider } from './viewport/dashboard_viewport_provider';
@ -83,6 +85,7 @@ app.directive('dashboardApp', function ($injector) {
const docTitle = Private(DocTitleProvider);
const embeddableFactories = Private(EmbeddableFactoriesRegistryProvider);
const panelActionsRegistry = Private(DashboardPanelActionsRegistryProvider);
const getUnhashableStates = Private(getUnhashableStatesProvider);
panelActionsStore.initializeFromRegistry(panelActionsRegistry);
@ -399,6 +402,16 @@ app.directive('dashboardApp', function ($injector) {
},
});
};
navActions[TopNavIds.SHARE] = (menuItem, navController, anchorElement) => {
showShareContextMenu({
anchorElement,
allowEmbed: true,
getUnhashableStates,
objectId: dash.id,
objectType: 'dashboard',
});
};
updateViewMode(dashboardStateManager.getViewMode());
// update root source when filters update
@ -438,11 +451,6 @@ app.directive('dashboardApp', function ($injector) {
kbnUrl.removeParam(DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM);
kbnUrl.removeParam(DashboardConstants.NEW_VISUALIZATION_ID_PARAM);
}
// TODO remove opts once share has been converted to react
$scope.opts = {
dashboard: dash, // used in share.html
};
}
};
});

View file

@ -38,7 +38,7 @@ export function getTopNavConfig(dashboardMode, actions, hideWriteControls) {
]
: [
getFullScreenConfig(actions[TopNavIds.FULL_SCREEN]),
getShareConfig(),
getShareConfig(actions[TopNavIds.SHARE]),
getCloneConfig(actions[TopNavIds.CLONE]),
getEditConfig(actions[TopNavIds.ENTER_EDIT_MODE])
]
@ -49,7 +49,7 @@ export function getTopNavConfig(dashboardMode, actions, hideWriteControls) {
getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]),
getAddConfig(actions[TopNavIds.ADD]),
getOptionsConfig(actions[TopNavIds.OPTIONS]),
getShareConfig()];
getShareConfig(actions[TopNavIds.SHARE])];
default:
return [];
}
@ -127,12 +127,12 @@ function getAddConfig(action) {
/**
* @returns {kbnTopNavConfig}
*/
function getShareConfig() {
function getShareConfig(action) {
return {
key: TopNavIds.SHARE,
description: 'Share Dashboard',
testId: 'dashboardShareButton',
template: require('plugins/kibana/dashboard/top_nav/share.html')
testId: 'shareTopNavButton',
run: action,
};
}

View file

@ -1,7 +0,0 @@
.dashOptionsPopover {
height: 100%;
.euiPopover__anchor {
height: 100%;
}
}

View file

@ -1,4 +0,0 @@
<share
object-type="dashboard"
object-id="{{opts.dashboard.id}}">
</share>

View file

@ -17,7 +17,6 @@
* under the License.
*/
import './options_popover.less';
import React from 'react';
import ReactDOM from 'react-dom';
@ -55,7 +54,7 @@ export function showOptionsPopover({
document.body.appendChild(container);
const element = (
<EuiWrappingPopover
className="dashOptionsPopover"
className="navbar__popover"
id="popover"
button={anchorElement}
isOpen={true}

View file

@ -31,7 +31,6 @@ import 'ui/filters/moment';
import 'ui/index_patterns';
import 'ui/state_management/app_state';
import { timefilter } from 'ui/timefilter';
import 'ui/share';
import 'ui/query_bar';
import { hasSearchStategyForIndexPattern, isDefaultTypeIndexPattern } from 'ui/courier';
import { toastNotifications } from 'ui/notify';
@ -54,6 +53,8 @@ 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 { getUnhashableStatesProvider } from 'ui/state_management/state_hashing';
const app = uiModules.get('apps/discover', [
'kibana/notify',
@ -157,6 +158,7 @@ function discoverController(
const notify = new Notifier({
location: 'Discover'
});
const getUnhashableStates = Private(getUnhashableStatesProvider);
$scope.getDocLink = getDocLink;
$scope.intervalOptions = intervalOptions;
@ -167,6 +169,10 @@ function discoverController(
return interval.val !== 'custom';
};
// the saved savedSearch
const savedSearch = $route.current.locals.savedSearch;
$scope.$on('$destroy', savedSearch.destroy);
$scope.topNavMenu = [{
key: 'new',
description: 'New Search',
@ -185,14 +191,18 @@ function discoverController(
}, {
key: 'share',
description: 'Share Search',
template: require('plugins/kibana/discover/partials/share_search.html'),
testId: 'discoverShareButton',
testId: 'shareTopNavButton',
run: (menuItem, navController, anchorElement) => {
showShareContextMenu({
anchorElement,
allowEmbed: false,
getUnhashableStates,
objectId: savedSearch.id,
objectType: 'search',
});
}
}];
// the saved savedSearch
const savedSearch = $route.current.locals.savedSearch;
$scope.$on('$destroy', savedSearch.destroy);
// the actual courier.SearchSource
$scope.searchSource = savedSearch.searchSource;
$scope.indexPattern = resolveIndexPatternLoading();

View file

@ -1,5 +0,0 @@
<share
object-type="search"
object-id="{{opts.savedSearch.id}}"
allow-embed="false">
</share>

View file

@ -23,7 +23,6 @@ import './visualization_editor';
import 'ui/vis/editors/default/sidebar';
import 'ui/visualize';
import 'ui/collapsible_sidebar';
import 'ui/share';
import 'ui/query_bar';
import chrome from 'ui/chrome';
import angular from 'angular';
@ -43,6 +42,8 @@ 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 { getUnhashableStatesProvider } from 'ui/state_management/state_hashing';
uiRoutes
.when(VisualizeConstants.CREATE_PATH, {
@ -115,6 +116,7 @@ function VisEditor(
) {
const docTitle = Private(DocTitleProvider);
const queryFilter = Private(FilterBarQueryFilterProvider);
const getUnhashableStates = Private(getUnhashableStatesProvider);
const notify = new Notifier({
location: 'Visualization Editor'
@ -156,8 +158,16 @@ function VisEditor(
}, {
key: 'share',
description: 'Share Visualization',
template: require('plugins/kibana/visualize/editor/panels/share.html'),
testId: 'visualizeShareButton',
testId: 'shareTopNavButton',
run: (menuItem, navController, anchorElement) => {
showShareContextMenu({
anchorElement,
allowEmbed: true,
getUnhashableStates,
objectId: savedVis.id,
objectType: 'visualization',
});
}
}, {
key: 'inspect',
description: 'Open Inspector for visualization',
@ -251,7 +261,7 @@ function VisEditor(
$scope.isAddToDashMode = () => addToDashMode;
$scope.timeRange = timefilter.getTime();
$scope.opts = _.pick($scope, 'doSave', 'savedVis', 'shareData', 'isAddToDashMode');
$scope.opts = _.pick($scope, 'doSave', 'savedVis', 'isAddToDashMode');
stateMonitor = stateMonitorFactory.create($state, stateDefaults);
stateMonitor.ignoreProps([ 'vis.listeners' ]).onChange((status) => {

View file

@ -1,4 +0,0 @@
<share
object-type="visualization"
object-id="{{opts.savedVis.id}}">
</share>

View file

@ -0,0 +1,65 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should only render permalink panel when there are no other panels 1`] = `
<EuiContextMenu
initialPanelId={1}
panels={
Array [
Object {
"content": <ShareUrlContent
getUnhashableStates={[Function]}
objectId={undefined}
objectType="dashboard"
/>,
"id": 1,
"title": "Permalink",
},
]
}
/>
`;
exports[`should render context menu panel when there are more than one panel 1`] = `
<EuiContextMenu
initialPanelId={3}
panels={
Array [
Object {
"content": <ShareUrlContent
getUnhashableStates={[Function]}
objectId={undefined}
objectType="dashboard"
/>,
"id": 1,
"title": "Permalink",
},
Object {
"content": <ShareUrlContent
getUnhashableStates={[Function]}
isEmbedded={true}
objectId={undefined}
objectType="dashboard"
/>,
"id": 2,
"title": "Embed Code",
},
Object {
"id": 3,
"items": Array [
Object {
"icon": "console",
"name": "Embed code",
"panel": 2,
},
Object {
"icon": "link",
"name": "Permalinks",
"panel": 1,
},
],
"title": "Share this dashboard",
},
]
}
/>
`;

View file

@ -0,0 +1,266 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`render 1`] = `
<EuiForm
className="shareUrlContentForm"
data-test-subj="shareUrlForm"
>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
helpText="Can't share as saved object until the dashboard has been saved."
label="Generate the link as"
>
<EuiRadioGroup
idSelected="snapshot"
onChange={[Function]}
options={
Array [
Object {
"data-test-subj": "exportAsSnapshot",
"id": "snapshot",
"label": <EuiFlexGroup
alignItems="stretch"
component="div"
direction="row"
gutterSize="none"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
Snapshot
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
>
<EuiIconTip
aria-label="Info"
content="Snapshot URLs encode the current state of the dashboard in the URL itself.
Edits to the saved dashboard won't be visible via this URL."
position="bottom"
type="questionInCircle"
/>
</EuiFlexItem>
</EuiFlexGroup>,
},
Object {
"data-test-subj": "exportAsSavedObject",
"disabled": true,
"id": "savedObject",
"label": <EuiFlexGroup
alignItems="stretch"
component="div"
direction="row"
gutterSize="none"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
Saved object
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
>
<EuiIconTip
aria-label="Info"
content="You can share this URL with people to let them load the most recent saved version of this dashboard."
position="bottom"
type="questionInCircle"
/>
</EuiFlexItem>
</EuiFlexGroup>,
},
]
}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
>
<EuiFlexGroup
alignItems="stretch"
component="div"
direction="row"
gutterSize="none"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<EuiSwitch
checked={false}
data-test-subj="useShortUrl"
label="Short URL"
onChange={[Function]}
/>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
>
<EuiIconTip
aria-label="Info"
content="We recommend sharing shortened snapshot URLs for maximum compatibility.
Internet Explorer has URL length restrictions,
and some wiki and markup parsers don't do well with the full-length version of the snapshot URL,
but the short URL should work great."
position="bottom"
type="questionInCircle"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
<EuiCopy
afterMessage="Copied"
textToCopy="about:blank"
/>
</EuiForm>
`;
exports[`should enable saved object export option when objectId is provided 1`] = `
<EuiForm
className="shareUrlContentForm"
data-test-subj="shareUrlForm"
>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
label="Generate the link as"
>
<EuiRadioGroup
idSelected="snapshot"
onChange={[Function]}
options={
Array [
Object {
"data-test-subj": "exportAsSnapshot",
"id": "snapshot",
"label": <EuiFlexGroup
alignItems="stretch"
component="div"
direction="row"
gutterSize="none"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
Snapshot
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
>
<EuiIconTip
aria-label="Info"
content="Snapshot URLs encode the current state of the dashboard in the URL itself.
Edits to the saved dashboard won't be visible via this URL."
position="bottom"
type="questionInCircle"
/>
</EuiFlexItem>
</EuiFlexGroup>,
},
Object {
"data-test-subj": "exportAsSavedObject",
"disabled": false,
"id": "savedObject",
"label": <EuiFlexGroup
alignItems="stretch"
component="div"
direction="row"
gutterSize="none"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
Saved object
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
>
<EuiIconTip
aria-label="Info"
content="You can share this URL with people to let them load the most recent saved version of this dashboard."
position="bottom"
type="questionInCircle"
/>
</EuiFlexItem>
</EuiFlexGroup>,
},
]
}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
>
<EuiFlexGroup
alignItems="stretch"
component="div"
direction="row"
gutterSize="none"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<EuiSwitch
checked={false}
data-test-subj="useShortUrl"
label="Short URL"
onChange={[Function]}
/>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
>
<EuiIconTip
aria-label="Info"
content="We recommend sharing shortened snapshot URLs for maximum compatibility.
Internet Explorer has URL length restrictions,
and some wiki and markup parsers don't do well with the full-length version of the snapshot URL,
but the short URL should work great."
position="bottom"
type="questionInCircle"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
<EuiCopy
afterMessage="Copied"
textToCopy="about:blank"
/>
</EuiForm>
`;

View file

@ -0,0 +1,45 @@
/*
* 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.
*/
jest.mock('../lib/url_shortener', () => ({}));
import React from 'react';
import { shallow } from 'enzyme';
import {
ShareContextMenu,
} from './share_context_menu';
test('should render context menu panel when there are more than one panel', () => {
const component = shallow(<ShareContextMenu
allowEmbed
objectType="dashboard"
getUnhashableStates={() => {}}
/>);
expect(component).toMatchSnapshot();
});
test('should only render permalink panel when there are no other panels', () => {
const component = shallow(<ShareContextMenu
allowEmbed={false}
objectType="dashboard"
getUnhashableStates={() => {}}
/>);
expect(component).toMatchSnapshot();
});

View file

@ -0,0 +1,99 @@
/*
* 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.
*/
import React, { Component } from 'react';
import { EuiContextMenu } from '@elastic/eui';
import { ShareUrlContent } from './share_url_content';
interface Props {
allowEmbed: boolean;
objectId?: string;
objectType: string;
getUnhashableStates: () => object[];
}
export class ShareContextMenu extends Component<Props> {
public render() {
const { panels, initialPanelId } = this.getPanels();
return <EuiContextMenu initialPanelId={initialPanelId} panels={panels} />;
}
private getPanels = () => {
const panels = [];
const menuItems = [];
const permalinkPanel = {
id: panels.length + 1,
title: 'Permalink',
content: (
<ShareUrlContent
objectId={this.props.objectId}
objectType={this.props.objectType}
getUnhashableStates={this.props.getUnhashableStates}
/>
),
};
menuItems.push({
name: 'Permalinks',
icon: 'link',
panel: permalinkPanel.id,
});
panels.push(permalinkPanel);
if (this.props.allowEmbed) {
const embedPanel = {
id: panels.length + 1,
title: 'Embed Code',
content: (
<ShareUrlContent
isEmbedded
objectId={this.props.objectId}
objectType={this.props.objectType}
getUnhashableStates={this.props.getUnhashableStates}
/>
),
};
panels.push(embedPanel);
menuItems.push({
name: 'Embed code',
icon: 'console',
panel: embedPanel.id,
});
}
// TODO add plugable panels here
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());
}),
};
panels.push(topLevelMenuPanel);
}
const lastPanelIndex = panels.length - 1;
const initialPanelId = panels[lastPanelIndex].id;
return { panels, initialPanelId };
};
}

View file

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

View file

@ -0,0 +1,44 @@
/*
* 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.
*/
jest.mock('../lib/url_shortener', () => ({}));
import React from 'react';
import { shallow } from 'enzyme';
import {
ShareUrlContent,
} from './share_url_content';
test('render', () => {
const component = shallow(<ShareUrlContent
objectType="dashboard"
getUnhashableStates={() => {}}
/>);
expect(component).toMatchSnapshot();
});
test('should enable saved object export option when objectId is provided', () => {
const component = shallow(<ShareUrlContent
objectId="id1"
objectType="dashboard"
getUnhashableStates={() => {}}
/>);
expect(component).toMatchSnapshot();
});

View file

@ -0,0 +1,347 @@
/*
* 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.
*/
// 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 React, { Component } from 'react';
import './share_url_content.less';
import {
EuiButton,
EuiCopy,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiIconTip,
EuiLoadingSpinner,
EuiRadioGroup,
EuiSwitch,
} from '@elastic/eui';
import { format as formatUrl, parse as parseUrl } from 'url';
import { unhashUrl } from '../../state_management/state_hashing';
import { shortenUrl } from '../lib/url_shortener';
// TODO: Remove once EuiIconTip supports "content" prop
const FixedEuiIconTip = EuiIconTip as React.SFC<any>;
interface Props {
isEmbedded?: boolean;
objectId?: string;
objectType: string;
getUnhashableStates: () => object[];
}
enum ExportUrlAsType {
EXPORT_URL_AS_SAVED_OBJECT = 'savedObject',
EXPORT_URL_AS_SNAPSHOT = 'snapshot',
}
interface State {
exportUrlAs: ExportUrlAsType;
useShortUrl: boolean;
isCreatingShortUrl: boolean;
url?: string;
shortUrlErrorMsg?: string;
}
export class ShareUrlContent extends Component<Props, State> {
private mounted?: boolean;
private shortUrlCache?: string;
constructor(props: Props) {
super(props);
this.shortUrlCache = undefined;
this.state = {
exportUrlAs: ExportUrlAsType.EXPORT_URL_AS_SNAPSHOT,
useShortUrl: false,
isCreatingShortUrl: false,
url: '',
};
}
public componentWillUnmount() {
window.removeEventListener('hashchange', this.resetUrl);
this.mounted = false;
}
public componentDidMount() {
this.mounted = true;
this.setUrl();
window.addEventListener('hashchange', this.resetUrl, false);
}
public render() {
return (
<EuiForm className="shareUrlContentForm" data-test-subj="shareUrlForm">
{this.renderExportAsRadioGroup()}
{this.renderShortUrlSwitch()}
<EuiCopy textToCopy={this.state.url}>
{(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>
)}
</EuiCopy>
</EuiForm>
);
}
private isNotSaved = () => {
return this.props.objectId === undefined || this.props.objectId === '';
};
private resetUrl = () => {
if (this.mounted) {
this.shortUrlCache = undefined;
this.setState(
{
useShortUrl: false,
},
this.setUrl
);
}
};
private getSavedObjectUrl = () => {
if (this.isNotSaved()) {
return;
}
const url = window.location.href;
// Replace hashes with original RISON values.
const unhashedUrl = unhashUrl(url, this.props.getUnhashableStates());
const parsedUrl = parseUrl(unhashedUrl);
if (!parsedUrl || !parsedUrl.hash) {
return;
}
// Get the application route, after the hash, and remove the #.
const parsedAppUrl = parseUrl(parsedUrl.hash.slice(1), true);
return formatUrl({
protocol: parsedUrl.protocol,
auth: parsedUrl.auth,
host: parsedUrl.host,
pathname: parsedUrl.pathname,
hash: formatUrl({
pathname: parsedAppUrl.pathname,
query: {
// Add global state to the URL so that the iframe doesn't just show the time range
// default.
_g: parsedAppUrl.query._g,
},
}),
});
};
private getSnapshotUrl = () => {
const url = window.location.href;
// Replace hashes with original RISON values.
return unhashUrl(url, this.props.getUnhashableStates());
};
private makeUrlEmbeddable = (url: string) => {
const embedQueryParam = '?embed=true';
const urlHasQueryString = url.indexOf('?') !== -1;
if (urlHasQueryString) {
return url.replace('?', `${embedQueryParam}&`);
}
return `${url}${embedQueryParam}`;
};
private makeIframeTag = (url?: string) => {
if (!url) {
return;
}
const embeddableUrl = this.makeUrlEmbeddable(url);
return `<iframe src="${embeddableUrl}" height="600" width="800"></iframe>`;
};
private setUrl = () => {
let url;
if (this.state.exportUrlAs === ExportUrlAsType.EXPORT_URL_AS_SAVED_OBJECT) {
url = this.getSavedObjectUrl();
} else if (this.state.useShortUrl) {
url = this.shortUrlCache;
} else {
url = this.getSnapshotUrl();
}
if (this.props.isEmbedded) {
url = this.makeIframeTag(url);
}
this.setState({ url });
};
private handleExportUrlAs = (optionId: string) => {
this.setState(
{
exportUrlAs: optionId as ExportUrlAsType,
},
this.setUrl
);
};
// TODO: switch evt type to ChangeEvent<HTMLInputElement> once https://github.com/elastic/eui/issues/1134 is resolved
private handleShortUrlChange = async (evt: any) => {
const isChecked = evt.target.checked;
if (!isChecked || this.shortUrlCache !== undefined) {
this.setState({ useShortUrl: isChecked }, this.setUrl);
return;
}
// "Use short URL" is checked but shortUrl has not been generated yet so one needs to be created.
this.setState({
isCreatingShortUrl: true,
shortUrlErrorMsg: undefined,
});
try {
const shortUrl = await shortenUrl(this.getSnapshotUrl());
if (this.mounted) {
this.shortUrlCache = shortUrl;
this.setState(
{
isCreatingShortUrl: false,
useShortUrl: isChecked,
},
this.setUrl
);
}
} catch (fetchError) {
if (this.mounted) {
this.shortUrlCache = undefined;
this.setState(
{
useShortUrl: false,
isCreatingShortUrl: false,
shortUrlErrorMsg: `Unable to create short URL. Error: ${fetchError.message}`,
},
this.setUrl
);
}
}
};
private renderExportUrlAsOptions = () => {
return [
{
id: ExportUrlAsType.EXPORT_URL_AS_SNAPSHOT,
label: this.renderWithIconTip(
'Snapshot',
`Snapshot URLs encode the current state of the ${this.props.objectType} in the URL itself.
Edits to the saved ${this.props.objectType} won't be visible via this URL.`
),
['data-test-subj']: 'exportAsSnapshot',
},
{
id: ExportUrlAsType.EXPORT_URL_AS_SAVED_OBJECT,
disabled: this.isNotSaved(),
label: this.renderWithIconTip(
'Saved object',
`You can share this URL with people to let them load the most recent saved version of this ${
this.props.objectType
}.`
),
['data-test-subj']: 'exportAsSavedObject',
},
];
};
private renderWithIconTip = (child: React.ReactNode, tipContent: React.ReactNode) => {
return (
<EuiFlexGroup gutterSize="none">
<EuiFlexItem>{child}</EuiFlexItem>
<EuiFlexItem grow={false}>
<FixedEuiIconTip content={tipContent} position="bottom" />
</EuiFlexItem>
</EuiFlexGroup>
);
};
private renderExportAsRadioGroup = () => {
const generateLinkAsHelp = this.isNotSaved()
? `Can't share as saved object until the ${this.props.objectType} has been saved.`
: undefined;
return (
<EuiFormRow label="Generate the link as" helpText={generateLinkAsHelp}>
<EuiRadioGroup
options={this.renderExportUrlAsOptions()}
idSelected={this.state.exportUrlAs}
onChange={this.handleExportUrlAs}
/>
</EuiFormRow>
);
};
private renderShortUrlSwitch = () => {
if (this.state.exportUrlAs === ExportUrlAsType.EXPORT_URL_AS_SAVED_OBJECT) {
return;
}
const switchLabel = this.state.isCreatingShortUrl ? (
<span>
<EuiLoadingSpinner size="s" /> Short URL
</span>
) : (
'Short URL'
);
const switchComponent = (
<EuiSwitch
label={switchLabel}
checked={this.state.useShortUrl}
onChange={this.handleShortUrlChange}
data-test-subj="useShortUrl"
/>
);
const tipContent = `We recommend sharing shortened snapshot URLs for maximum compatibility.
Internet Explorer has URL length restrictions,
and some wiki and markup parsers don't do well with the full-length version of the snapshot URL,
but the short URL should work great.`;
return (
<EuiFormRow helpText={this.state.shortUrlErrorMsg}>
{this.renderWithIconTip(switchComponent, tipContent)}
</EuiFormRow>
);
};
}

View file

@ -1,198 +0,0 @@
/*
* 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.
*/
import {
parse as parseUrl,
format as formatUrl,
} from 'url';
import {
getUnhashableStatesProvider,
unhashUrl,
} from '../../state_management/state_hashing';
import { toastNotifications } from '../../notify';
import { shortenUrl } from '../lib/url_shortener';
import { uiModules } from '../../modules';
import shareTemplate from '../views/share.html';
const app = uiModules.get('kibana');
app.directive('share', function (Private) {
const getUnhashableStates = Private(getUnhashableStatesProvider);
return {
restrict: 'E',
scope: {
objectType: '@',
objectId: '@',
allowEmbed: '@',
},
template: shareTemplate,
controllerAs: 'share',
controller: function ($scope, $document, $location) {
if ($scope.allowEmbed !== 'false' && $scope.allowEmbed !== undefined) {
throw new Error('allowEmbed must be "false" or undefined');
}
// Default to allowing an embedded IFRAME, unless it's explicitly set to false.
this.allowEmbed = $scope.allowEmbed === 'false' ? false : true;
this.objectType = $scope.objectType;
function getOriginalUrl() {
// If there is no objectId, then it isn't saved, so it has no original URL.
if ($scope.objectId === undefined || $scope.objectId === '') {
return;
}
const url = $location.absUrl();
// Replace hashes with original RISON values.
const unhashedUrl = unhashUrl(url, getUnhashableStates());
const parsedUrl = parseUrl(unhashedUrl);
// Get the Angular route, after the hash, and remove the #.
const parsedAppUrl = parseUrl(parsedUrl.hash.slice(1), true);
return formatUrl({
protocol: parsedUrl.protocol,
auth: parsedUrl.auth,
host: parsedUrl.host,
pathname: parsedUrl.pathname,
hash: formatUrl({
pathname: parsedAppUrl.pathname,
query: {
// Add global state to the URL so that the iframe doesn't just show the time range
// default.
_g: parsedAppUrl.query._g,
},
}),
});
}
function getSnapshotUrl() {
const url = $location.absUrl();
// Replace hashes with original RISON values.
return unhashUrl(url, getUnhashableStates());
}
this.makeUrlEmbeddable = url => {
const embedQueryParam = '?embed=true';
const urlHasQueryString = url.indexOf('?') !== -1;
if (urlHasQueryString) {
return url.replace('?', `${embedQueryParam}&`);
}
return `${url}${embedQueryParam}`;
};
this.makeIframeTag = url => {
if (!url) return;
const embeddableUrl = this.makeUrlEmbeddable(url);
return `<iframe src="${embeddableUrl}" height="600" width="800"></iframe>`;
};
this.urls = {
original: undefined,
snapshot: undefined,
shortSnapshot: undefined,
shortSnapshotIframe: undefined,
};
this.urlFlags = {
shortSnapshot: false,
shortSnapshotIframe: false,
};
const updateUrls = () => {
this.urls = {
original: getOriginalUrl(),
snapshot: getSnapshotUrl(),
shortSnapshot: undefined,
shortSnapshotIframe: undefined,
};
// Whenever the URL changes, reset the Short URLs to regular URLs.
this.urlFlags = {
shortSnapshot: false,
shortSnapshotIframe: false,
};
};
// When the URL changes, update the links in the UI.
$scope.$watch(() => $location.absUrl(), () => {
updateUrls();
});
this.toggleShortSnapshotUrl = () => {
this.urlFlags.shortSnapshot = !this.urlFlags.shortSnapshot;
if (this.urlFlags.shortSnapshot) {
shortenUrl(this.urls.snapshot)
.then(shortUrl => {
// We're using ES6 Promises, not $q, so we have to wrap this in $apply.
$scope.$apply(() => {
this.urls.shortSnapshot = shortUrl;
});
});
}
};
this.toggleShortSnapshotIframeUrl = () => {
this.urlFlags.shortSnapshotIframe = !this.urlFlags.shortSnapshotIframe;
if (this.urlFlags.shortSnapshotIframe) {
const snapshotIframe = this.makeUrlEmbeddable(this.urls.snapshot);
shortenUrl(snapshotIframe)
.then(shortUrl => {
// We're using ES6 Promises, not $q, so we have to wrap this in $apply.
$scope.$apply(() => {
this.urls.shortSnapshotIframe = shortUrl;
});
});
}
};
this.copyToClipboard = selector => {
// Select the text to be copied. If the copy fails, the user can easily copy it manually.
const copyTextarea = $document.find(selector)[0];
copyTextarea.select();
try {
const isCopied = document.execCommand('copy');
if (isCopied) {
toastNotifications.add({
title: 'URL was copied to the clipboard',
'data-test-subj': 'shareCopyToClipboardSuccess',
});
} else {
toastNotifications.add({
title: 'URL selected. Press Ctrl+C to copy.',
'data-test-subj': 'shareCopyToClipboardSuccess',
});
}
} catch (err) {
toastNotifications.add({
title: 'URL selected. Press Ctrl+C to copy.',
'data-test-subj': 'shareCopyToClipboardSuccess',
});
}
};
}
};
});

View file

@ -17,4 +17,4 @@
* under the License.
*/
import './directives/share';
export { showShareContextMenu } from './show_share_context_menu';

View file

@ -20,13 +20,6 @@ jest.mock('ui/kfetch', () => ({}));
jest.mock('../../chrome', () => ({}));
jest.mock('ui/notify',
() => ({
toastNotifications: {
addDanger: () => {},
}
}), { virtual: true });
import sinon from 'sinon';
import expect from 'expect.js';
import { shortenUrl } from './url_shortener';

View file

@ -17,32 +17,27 @@
* under the License.
*/
import chrome from '../../chrome';
import url from 'url';
import { kfetch } from 'ui/kfetch';
import { toastNotifications } from 'ui/notify';
import url from 'url';
import chrome from '../../chrome';
export async function shortenUrl(absoluteUrl) {
export async function shortenUrl(absoluteUrl: string) {
const basePath = chrome.getBasePath();
const parsedUrl = url.parse(absoluteUrl);
if (!parsedUrl || !parsedUrl.path) {
return;
}
const path = parsedUrl.path.replace(basePath, '');
const hash = parsedUrl.hash ? parsedUrl.hash : '';
const relativeUrl = path + hash;
const body = JSON.stringify({ url: relativeUrl });
try {
const resp = await kfetch({ method: 'POST', 'pathname': '/api/shorten_url', body });
return url.format({
protocol: parsedUrl.protocol,
host: parsedUrl.host,
pathname: `${basePath}/goto/${resp.urlId}`
});
} catch (fetchError) {
toastNotifications.addDanger({
title: `Unable to create short URL. Error: ${fetchError.message}`,
'data-test-subj': 'shortenUrlFailure',
});
}
const resp = await kfetch({ method: 'POST', pathname: '/api/shorten_url', body });
return url.format({
protocol: parsedUrl.protocol,
host: parsedUrl.host,
pathname: `${basePath}/goto/${resp.urlId}`,
});
}

View file

@ -0,0 +1,83 @@
/*
* 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.
*/
// TODO: Remove once typescript definitions are in EUI
declare module '@elastic/eui' {
export const EuiWrappingPopover: React.SFC<any>;
}
import React from 'react';
import ReactDOM from 'react-dom';
import { ShareContextMenu } from './components/share_context_menu';
import { EuiWrappingPopover } from '@elastic/eui';
let isOpen = false;
const container = document.createElement('div');
const onClose = () => {
ReactDOM.unmountComponentAtNode(container);
isOpen = false;
};
interface ShowProps {
anchorElement: any;
allowEmbed: boolean;
getUnhashableStates: () => object[];
objectId?: string;
objectType: string;
}
export function showShareContextMenu({
anchorElement,
allowEmbed,
getUnhashableStates,
objectId,
objectType,
}: ShowProps) {
if (isOpen) {
onClose();
return;
}
isOpen = true;
document.body.appendChild(container);
const element = (
<EuiWrappingPopover
className="navbar__popover"
id="sharePopover"
button={anchorElement}
isOpen={true}
closePopover={onClose}
panelPaddingSize="none"
withTitle
>
<ShareContextMenu
allowEmbed={allowEmbed}
getUnhashableStates={getUnhashableStates}
objectId={objectId}
objectType={objectType}
/>
</EuiWrappingPopover>
);
ReactDOM.render(element, container);
}

View file

@ -1,238 +0,0 @@
<div class="kuiLocalDropdownPanels">
<!-- Left panel -->
<div class="kuiLocalDropdownPanel kuiLocalDropdownPanel--left">
<!-- Title -->
<h2
data-test-subj="shareUiTitle"
class="kuiLocalDropdownTitle"
>
Share saved {{share.objectType}}
</h2>
<!-- Help text -->
<div ng-if="share.urls.original" class="kuiLocalDropdownHelpText">
You can share this URL with people to let them load the most recent saved version of this {{share.objectType}}.
</div>
<div ng-if="!share.urls.original" class="kuiLocalDropdownWarning">
Please save this {{share.objectType}} to enable this sharing option.
</div>
<div ng-if="share.urls.original">
<!-- iframe -->
<div class="kuiLocalDropdownSection" ng-if="share.allowEmbed">
<!-- Header -->
<div class="kuiLocalDropdownHeader">
<label
id="originalIframeUrlLabel"
class="kuiLocalDropdownHeader__label"
for="originalIframeUrl"
>
Embedded iframe
</label>
<div class="kuiLocalDropdownHeader__actions">
<a
aria-describedby="originalIframeUrlLabel"
class="kuiLocalDropdownHeader__action"
ng-click="share.copyToClipboard('#originalIframeUrl')"
kbn-accessible-click
>
Copy
</a>
</div>
</div>
<!-- Input -->
<input
id="originalIframeUrl"
aria-describedby="originalIframeUrlHelpText"
class="kuiLocalDropdownInput"
type="text"
readonly
value="{{share.makeIframeTag(share.urls.original)}}"
/>
<!-- Notes -->
<div
id="originalIframeUrlHelpText"
class="kuiLocalDropdownFormNote"
>
Add to your HTML source. Note that all clients must be able to access Kibana.
</div>
</div>
<!-- Link -->
<div class="kuiLocalDropdownSection">
<!-- Header -->
<div class="kuiLocalDropdownHeader">
<label
id="originalUrlLabel"
class="kuiLocalDropdownHeader__label"
for="originalUrl"
>
Link
</label>
<div class="kuiLocalDropdownHeader__actions">
<a
aria-describedby="originalUrlLabel"
class="kuiLocalDropdownHeader__action"
ng-click="share.copyToClipboard('#originalUrl')"
kbn-accessible-click
>
Copy
</a>
</div>
</div>
<!-- Input -->
<input
id="originalUrl"
class="kuiLocalDropdownInput"
type="text"
readonly
value="{{share.urls.original}}"
/>
</div>
</div>
</div>
<!-- Right panel -->
<div class="kuiLocalDropdownPanel kuiLocalDropdownPanel--right">
<!-- Title -->
<h2 class="kuiLocalDropdownTitle">
Share Snapshot
</h2>
<!-- Help text -->
<div class="kuiLocalDropdownHelpText">
Snapshot URLs encode the current state of the {{share.objectType}} in the URL itself. Edits to the saved {{share.objectType}} won't be visible via this URL.
</div>
<!-- iframe -->
<div class="kuiLocalDropdownSection" ng-if="share.allowEmbed">
<!-- Header -->
<div class="kuiLocalDropdownHeader">
<label
id="snapshotIframeUrlLabel"
class="kuiLocalDropdownHeader__label"
for="snapshotIframeUrl"
>
Embedded iframe
</label>
<div class="kuiLocalDropdownHeader__actions">
<a
aria-describedby="snapshotIframeUrlLabel"
class="kuiLocalDropdownHeader__action"
ng-if="!share.urlFlags.shortSnapshotIframe"
ng-click="share.toggleShortSnapshotIframeUrl()"
kbn-accessible-click
>
Short URL
</a>
<a
aria-describedby="snapshotIframeUrlLabel"
class="kuiLocalDropdownHeader__action"
ng-if="share.urlFlags.shortSnapshotIframe"
ng-click="share.toggleShortSnapshotIframeUrl()"
kbn-accessible-click
>
Long URL
</a>
<a
aria-describedby="snapshotIframeUrlLabel"
class="kuiLocalDropdownHeader__action"
ng-click="share.copyToClipboard('#snapshotIframeUrl')"
kbn-accessible-click
>
Copy
</a>
</div>
</div>
<!-- Input -->
<input
id="snapshotIframeUrl"
aria-describedby="snapshotIframeUrlHelpText"
class="kuiLocalDropdownInput"
type="text"
readonly
value="{{share.urlFlags.shortSnapshotIframe ? share.makeIframeTag(share.urls.shortSnapshotIframe) : share.makeIframeTag(share.urls.snapshot)}}"
/>
<!-- Notes -->
<div
id="snapshotIframeUrlHelpText"
class="kuiLocalDropdownFormNote"
>
Add to your HTML source. Note that all clients must be able to access Kibana.
</div>
</div>
<!-- Link -->
<div class="kuiLocalDropdownSection">
<!-- Header -->
<div class="kuiLocalDropdownHeader">
<label
id="snapshotUrlLabel"
class="kuiLocalDropdownHeader__label"
for="snapshotUrl"
>
Link
</label>
<div class="kuiLocalDropdownHeader__actions">
<a
aria-describedby="snapshotUrlLabel"
data-test-subj="sharedSnapshotShortUrlButton"
class="kuiLocalDropdownHeader__action"
ng-if="!share.urlFlags.shortSnapshot"
ng-click="share.toggleShortSnapshotUrl()"
kbn-accessible-click
>
Short URL
</a>
<a
aria-describedby="snapshotUrlLabel"
class="kuiLocalDropdownHeader__action"
ng-if="share.urlFlags.shortSnapshot"
ng-click="share.toggleShortSnapshotUrl()"
kbn-accessible-click
>
Long URL
</a>
<a
aria-describedby="snapshotUrlLabel"
data-test-subj="sharedSnapshotCopyButton"
class="kuiLocalDropdownHeader__action"
ng-click="share.copyToClipboard('#snapshotUrl')"
kbn-accessible-click
>
Copy
</a>
</div>
</div>
<!-- Input -->
<input
data-test-subj="sharedSnapshotUrl"
id="snapshotUrl"
aria-describedby="snapshotUrlHelpText"
class="kuiLocalDropdownInput"
type="text"
readonly
value="{{share.urlFlags.shortSnapshot ? share.urls.shortSnapshot : share.urls.snapshot}}"
/>
<!-- Notes -->
<div
id="snapshotUrlHelpText"
class="kuiLocalDropdownFormNote"
>
We recommend sharing shortened snapshot URLs for maximum compatibility. Internet Explorer has URL length restrictions, and some wiki and markup parsers don't do well with the full-length version of the snapshot URL, but the short URL should work great.
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,20 @@
/*
* 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.
*/
export function unhashUrl(url: string, kbnStates: any[]): any;

View file

@ -111,3 +111,11 @@ navbar {
}
}
}
.navbar__popover {
height: 100%;
.euiPopover__anchor {
height: 100%;
}
}

View file

@ -24,7 +24,7 @@ export default function ({ getService, getPageObjects }) {
const log = getService('log');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const PageObjects = getPageObjects(['common', 'discover', 'header']);
const PageObjects = getPageObjects(['common', 'discover', 'header', 'share']);
describe('shared links', function describeIndexTests() {
let baseUrl;
@ -59,20 +59,13 @@ export default function ({ getService, getPageObjects }) {
//After hiding the time picker, we need to wait for
//the refresh button to hide before clicking the share button
return PageObjects.common.sleep(1000);
await PageObjects.common.sleep(1000);
await PageObjects.share.clickShareTopNavButton();
});
describe('shared link', function () {
it('should show "Share a link" caption', async function () {
const expectedCaption = 'Share saved';
await PageObjects.discover.clickShare();
const actualCaption = await PageObjects.discover.getShareCaption();
expect(actualCaption).to.contain(expectedCaption);
});
it('should show the correct formatted URL', async function () {
describe('permalink', function () {
it('should allow for copying the snapshot URL', async function () {
const expectedUrl =
baseUrl +
'/app/kibana?_t=1453775307251#' +
@ -81,32 +74,35 @@ export default function ({ getService, getPageObjects }) {
'-23T18:31:44.000Z\'))&_a=(columns:!(_source),index:\'logstash-' +
'*\',interval:auto,query:(language:lucene,query:\'\')' +
',sort:!(\'@timestamp\',desc))';
const actualUrl = await PageObjects.discover.getSharedUrl();
const actualUrl = await PageObjects.share.getSharedUrl();
// strip the timestamp out of each URL
expect(actualUrl.replace(/_t=\d{13}/, '_t=TIMESTAMP')).to.be(
expectedUrl.replace(/_t=\d{13}/, '_t=TIMESTAMP')
);
});
it('gets copied to clipboard', async function () {
const isCopiedToClipboard = await PageObjects.discover.clickCopyToClipboard();
expect(isCopiedToClipboard).to.eql(true);
});
// TODO: verify clipboard contents
it('shorten URL button should produce a short URL', async function () {
it('should allow for copying the snapshot URL as a short URL', async function () {
const re = new RegExp(baseUrl + '/goto/[0-9a-f]{32}$');
await PageObjects.discover.clickShortenUrl();
await retry.try(async function tryingForTime() {
const actualUrl = await PageObjects.discover.getSharedUrl();
await PageObjects.share.checkShortenUrl();
await retry.try(async () => {
const actualUrl = await PageObjects.share.getSharedUrl();
expect(actualUrl).to.match(re);
});
});
// NOTE: This test has to run immediately after the test above
it('copies short URL to clipboard', async function () {
const isCopiedToClipboard = await PageObjects.discover.clickCopyToClipboard();
expect(isCopiedToClipboard).to.eql(true);
it('should allow for copying the saved object URL', async function () {
const expectedUrl =
baseUrl +
'/app/kibana#' +
'/discover/ab12e3c0-f231-11e6-9486-733b1ac9221a' +
'?_g=(refreshInterval%3A(pause%3A!t%2Cvalue%3A0)' +
'%2Ctime%3A(from%3A\'2015-09-19T06%3A31%3A44.000Z\'%2C' +
'mode%3Aabsolute%2Cto%3A\'2015-09-23T18%3A31%3A44.000Z\'))';
await PageObjects.discover.loadSavedSearch('A Saved Search');
await PageObjects.share.clickShareTopNavButton();
await PageObjects.share.exportAsSavedObject();
const actualUrl = await PageObjects.share.getSharedUrl();
expect(actualUrl).to.be(expectedUrl);
});
});
});

View file

@ -32,6 +32,7 @@ import {
PointSeriesPageProvider,
VisualBuilderPageProvider,
TimelionPageProvider,
SharePageProvider
} from './page_objects';
import {
@ -85,7 +86,8 @@ export default async function ({ readConfigFile }) {
monitoring: MonitoringPageProvider,
pointSeries: PointSeriesPageProvider,
visualBuilder: VisualBuilderPageProvider,
timelion: TimelionPageProvider
timelion: TimelionPageProvider,
share: SharePageProvider,
},
services: {
es: commonConfig.get('services.es'),

View file

@ -226,29 +226,6 @@ export function DiscoverPageProvider({ getService, getPageObjects }) {
.getVisibleText();
}
clickShare() {
return testSubjects.click('discoverShareButton');
}
clickShortenUrl() {
return testSubjects.click('sharedSnapshotShortUrlButton');
}
async clickCopyToClipboard() {
await testSubjects.click('sharedSnapshotCopyButton');
// Confirm that the content was copied to the clipboard.
return await testSubjects.exists('shareCopyToClipboardSuccess');
}
async getShareCaption() {
return await testSubjects.getVisibleText('shareUiTitle');
}
async getSharedUrl() {
return await testSubjects.getProperty('sharedSnapshotUrl', 'value');
}
async toggleSidebarCollapse() {
return await testSubjects.click('collapseSideBarButton');
}

View file

@ -31,3 +31,4 @@ export { MonitoringPageProvider } from './monitoring_page';
export { PointSeriesPageProvider } from './point_series_page';
export { VisualBuilderPageProvider } from './visual_builder_page';
export { TimelionPageProvider } from './timelion_page';
export { SharePageProvider } from './share_page';

View file

@ -0,0 +1,46 @@
/*
* 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.
*/
export function SharePageProvider({ getService, getPageObjects }) {
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['visualize']);
class SharePage {
async clickShareTopNavButton() {
return testSubjects.click('shareTopNavButton');
}
async getSharedUrl() {
return await testSubjects.getAttribute('copyShareUrlButton', 'data-share-url');
}
async checkShortenUrl() {
const shareForm = await testSubjects.find('shareUrlForm');
await PageObjects.visualize.checkCheckbox('useShortUrl');
await shareForm.waitForDeletedByClassName('euiLoadingSpinner');
}
async exportAsSavedObject() {
return await testSubjects.click('exportAsSavedObject');
}
}
return new SharePage();
}

View file

@ -158,7 +158,7 @@ export default function ({ getService, getPageObjects }) {
});
it('does not show the sharing menu item', async () => {
const shareMenuItemExists = await testSubjects.exists('dashboardShareButton');
const shareMenuItemExists = await testSubjects.exists('shareTopNavButton');
expect(shareMenuItemExists).to.be(false);
});