feat: one-click copy chart and dashboard URL (#13037)

Closes #10328
This commit is contained in:
Michael S. Molina
2021-02-17 19:14:08 -03:00
committed by GitHub
parent 91db008d72
commit ad4ca2223e
22 changed files with 422 additions and 344 deletions

View File

@@ -48,7 +48,7 @@ describe('Test explore links', () => {
});
});
it('Visit short link', () => {
it('Test if short link is generated', () => {
cy.intercept('POST', 'r/shortner/').as('getShortUrl');
cy.visitChartByName('Growth Rate');
@@ -58,13 +58,6 @@ describe('Test explore links', () => {
// explicitly wait for the url response
cy.wait('@getShortUrl');
cy.get('#shorturl-popover [data-test="short-url"]')
.invoke('text')
.then(text => {
cy.visit(text);
});
cy.verifySliceSuccess({ waitAlias: '@chartData' });
});
it('Test iframe link', () => {

View File

@@ -20,11 +20,11 @@ import React from 'react';
import { shallow } from 'enzyme';
import { Menu, NoAnimationDropdown } from 'src/common/components';
import RefreshIntervalModal from 'src/dashboard/components/RefreshIntervalModal';
import URLShortLinkModal from 'src/components/URLShortLinkModal';
import HeaderActionsDropdown from 'src/dashboard/components/HeaderActionsDropdown';
import SaveModal from 'src/dashboard/components/SaveModal';
import CssEditor from 'src/dashboard/components/CssEditor';
import fetchMock from 'fetch-mock';
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
@@ -79,9 +79,9 @@ describe('HeaderActionsDropdown', () => {
expect(menu.find(SaveModal)).not.toExist();
});
it('should render five Menu items', () => {
it('should render available Menu items', () => {
const { menu } = setup(overrideProps);
expect(menu.find(Menu.Item)).toHaveLength(5);
expect(menu.find(Menu.Item)).toHaveLength(4);
});
it('should render the RefreshIntervalModal', () => {
@@ -89,9 +89,9 @@ describe('HeaderActionsDropdown', () => {
expect(menu.find(RefreshIntervalModal)).toExist();
});
it('should render the URLShortLinkModal', () => {
it('should render the ShareMenuItems', () => {
const { menu } = setup(overrideProps);
expect(menu.find(URLShortLinkModal)).toExist();
expect(menu.find(ShareMenuItems)).toExist();
});
it('should not render the CssEditor', () => {
@@ -113,9 +113,9 @@ describe('HeaderActionsDropdown', () => {
expect(menu.find(SaveModal)).toExist();
});
it('should render six Menu items', () => {
it('should render available Menu items', () => {
const { menu } = setup(overrideProps);
expect(menu.find(Menu.Item)).toHaveLength(6);
expect(menu.find(Menu.Item)).toHaveLength(5);
});
it('should render the RefreshIntervalModal', () => {
@@ -123,9 +123,9 @@ describe('HeaderActionsDropdown', () => {
expect(menu.find(RefreshIntervalModal)).toExist();
});
it('should render the URLShortLinkModal', () => {
it('should render the ShareMenuItems', () => {
const { menu } = setup(overrideProps);
expect(menu.find(URLShortLinkModal)).toExist();
expect(menu.find(ShareMenuItems)).toExist();
});
it('should not render the CssEditor', () => {
@@ -147,9 +147,9 @@ describe('HeaderActionsDropdown', () => {
expect(menu.find(SaveModal)).toExist();
});
it('should render seven MenuItems', () => {
it('should render available MenuItems', () => {
const { menu } = setup(overrideProps);
expect(menu.find(Menu.Item)).toHaveLength(7);
expect(menu.find(Menu.Item)).toHaveLength(6);
});
it('should render the RefreshIntervalModal', () => {
@@ -157,9 +157,9 @@ describe('HeaderActionsDropdown', () => {
expect(menu.find(RefreshIntervalModal)).toExist();
});
it('should render the URLShortLinkModal', () => {
it('should render the ShareMenuItems', () => {
const { menu } = setup(overrideProps);
expect(menu.find(URLShortLinkModal)).toExist();
expect(menu.find(ShareMenuItems)).toExist();
});
it('should render the CssEditor', () => {

View File

@@ -57,6 +57,7 @@ describe('Chart', () => {
changeFilter() {},
setFocusedFilterField() {},
unsetFocusedFilterField() {},
addSuccessToast() {},
addDangerToast() {},
componentId: 'test',
dashboardId: 111,

View File

@@ -86,8 +86,12 @@ describe('EmbedCodeButton', () => {
const stub = sinon
.stub(exploreUtils, 'getURIDirectory')
.callsFake(() => 'endpoint_url');
const wrapper = mount(<EmbedCodeButton {...defaultProps} />);
wrapper.setState({
const wrapper = mount(
<ThemeProvider theme={supersetTheme}>
<EmbedCodeButton {...defaultProps} />
</ThemeProvider>,
);
wrapper.find(EmbedCodeButton).setState({
height: '1000',
width: '2000',
shortUrlId: 100,
@@ -104,7 +108,9 @@ describe('EmbedCodeButton', () => {
}${DashboardStandaloneMode.HIDE_NAV}&height=1000"\n` +
`>\n` +
`</iframe>`;
expect(wrapper.instance().generateEmbedHTML()).toBe(embedHTML);
expect(wrapper.find(EmbedCodeButton).instance().generateEmbedHTML()).toBe(
embedHTML,
);
stub.restore();
});
});

View File

@@ -18,6 +18,7 @@
*/
import React from 'react';
import { shallow } from 'enzyme';
import { mockStore } from 'spec/fixtures/mockStore';
import ExploreActionButtons from 'src/explore/components/ExploreActionButtons';
describe('ExploreActionButtons', () => {
@@ -35,8 +36,10 @@ describe('ExploreActionButtons', () => {
).toBe(true);
});
it('should render 5 children/buttons', () => {
const wrapper = shallow(<ExploreActionButtons {...defaultProps} />);
expect(wrapper.children()).toHaveLength(5);
it('should render 6 children/buttons', () => {
const wrapper = shallow(
<ExploreActionButtons {...defaultProps} store={mockStore} />,
);
expect(wrapper.dive().children()).toHaveLength(6);
});
});

View File

@@ -16,31 +16,24 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import configureStore from 'redux-mock-store';
import { shallow } from 'enzyme';
import { useState, useEffect } from 'react';
import { getShortUrl as getShortUrlUtil } from 'src/utils/urlUtils';
import URLShortLinkModal from 'src/components/URLShortLinkModal';
import ModalTrigger from 'src/components/ModalTrigger';
export function useUrlShortener(url: string): Function {
const [update, setUpdate] = useState(false);
const [shortUrl, setShortUrl] = useState('');
describe('URLShortLinkModal', () => {
const defaultProps = {
url: 'mockURL',
emailSubject: 'Mock Subject',
emailContent: 'mock content',
triggerNode: <div />,
};
function setup() {
const mockStore = configureStore([]);
const store = mockStore({});
return shallow(
<URLShortLinkModal store={store} {...defaultProps} />,
).dive();
async function getShortUrl() {
if (update) {
const newShortUrl = await getShortUrlUtil(url);
setShortUrl(newShortUrl);
setUpdate(false);
return newShortUrl;
}
return shortUrl;
}
it('renders ModalTrigger', () => {
const wrapper = setup();
expect(wrapper.find(ModalTrigger)).toExist();
});
});
useEffect(() => setUpdate(true), [url]);
return getShortUrl;
}

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { CSSProperties } from 'react';
import React, { CSSProperties, Children, ReactElement } from 'react';
import { kebabCase } from 'lodash';
import { mix } from 'polished';
import cx from 'classnames';
@@ -144,6 +144,17 @@ export default function Button(props: ButtonProps) {
colorHover = primary.base;
}
const element = children as ReactElement;
let renderedChildren = [];
if (element && element.type === React.Fragment) {
renderedChildren = Children.toArray(element.props.children);
} else {
renderedChildren = Children.toArray(children);
}
const firstChildMargin = renderedChildren.length > 1 ? theme.gridUnit * 2 : 0;
const button = (
<AntdButton
href={disabled ? undefined : href}
@@ -188,14 +199,13 @@ export default function Button(props: ButtonProps) {
backgroundColor: backgroundColorDisabled,
borderColor: borderColorDisabled,
},
'i:first-of-type, svg:first-of-type': {
marginRight: theme.gridUnit * 2,
padding: `0 ${theme.gridUnit * 2} 0 0`,
},
marginLeft: theme.gridUnit * 2,
'&:first-of-type': {
marginLeft: 0,
},
'& :first-of-type': {
marginRight: firstChildMargin,
},
}}
{...restProps}
>

View File

@@ -32,6 +32,7 @@ const propTypes = {
wrapped: PropTypes.bool,
tooltipText: PropTypes.string,
addDangerToast: PropTypes.func.isRequired,
addSuccessToast: PropTypes.func.isRequired,
};
const defaultProps = {
@@ -45,22 +46,12 @@ const defaultProps = {
class CopyToClipboard extends React.Component {
constructor(props) {
super(props);
this.state = {
tooltipText: this.props.tooltipText,
};
// bindings
this.copyToClipboard = this.copyToClipboard.bind(this);
this.resetTooltipText = this.resetTooltipText.bind(this);
this.onMouseOut = this.onMouseOut.bind(this);
this.onClick = this.onClick.bind(this);
}
onMouseOut() {
// delay to avoid flash of text change on tooltip
setTimeout(this.resetTooltipText, 200);
}
onClick() {
if (this.props.getText) {
this.props.getText(d => {
@@ -75,18 +66,13 @@ class CopyToClipboard extends React.Component {
return React.cloneElement(this.props.copyNode, {
style: { cursor: 'pointer' },
onClick: this.onClick,
onMouseOut: this.onMouseOut,
});
}
resetTooltipText() {
this.setState({ tooltipText: this.props.tooltipText });
}
copyToClipboard(textToCopy) {
copyTextToClipboard(textToCopy)
.then(() => {
this.setState({ tooltipText: t('Copied!') });
this.props.addSuccessToast(t('Copied to clipboard!'));
})
.catch(() => {
this.props.addDangerToast(
@@ -106,10 +92,8 @@ class CopyToClipboard extends React.Component {
id="copy-to-clipboard-tooltip"
placement="top"
style={{ cursor: 'pointer' }}
title={this.state.tooltipText}
title={this.props.tooltipText}
trigger={['hover']}
onClick={this.onClick}
onMouseOut={this.onMouseOut}
>
{this.getDecoratedCopyNode()}
</Tooltip>
@@ -127,7 +111,7 @@ class CopyToClipboard extends React.Component {
<Tooltip
id="copy-to-clipboard-tooltip"
placement="top"
title={this.state.tooltipText}
title={this.props.tooltipText}
trigger={['hover']}
>
{this.getDecoratedCopyNode()}

View File

@@ -1,105 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF 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 from 'react';
import { t } from '@superset-ui/core';
import CopyToClipboard from './CopyToClipboard';
import { getShortUrl } from '../utils/urlUtils';
import withToasts from '../messageToasts/enhancers/withToasts';
import ModalTrigger from './ModalTrigger';
type URLShortLinkModalProps = {
url: string;
emailSubject: string;
emailContent: string;
title?: string;
addDangerToast: (msg: string) => void;
triggerNode: JSX.Element;
};
type URLShortLinkModalState = {
shortUrl: string;
};
class URLShortLinkModal extends React.Component<
URLShortLinkModalProps,
URLShortLinkModalState
> {
static defaultProps = {
url: window.location.href.substring(window.location.origin.length),
emailSubject: '',
emailContent: '',
};
modal: ModalTrigger | null;
constructor(props: URLShortLinkModalProps) {
super(props);
this.state = {
shortUrl: '',
};
this.modal = null;
this.setModalRef = this.setModalRef.bind(this);
this.onShortUrlSuccess = this.onShortUrlSuccess.bind(this);
this.getCopyUrl = this.getCopyUrl.bind(this);
}
onShortUrlSuccess(shortUrl: string) {
this.setState(() => ({ shortUrl }));
}
setModalRef(ref: ModalTrigger | null) {
this.modal = ref;
}
getCopyUrl() {
getShortUrl(this.props.url)
.then(this.onShortUrlSuccess)
.catch(this.props.addDangerToast);
}
render() {
const emailBody = t('%s%s', this.props.emailContent, this.state.shortUrl);
return (
<ModalTrigger
ref={this.setModalRef}
triggerNode={this.props.triggerNode}
beforeOpen={this.getCopyUrl}
modalTitle={this.props.title || t('Share dashboard')}
modalBody={
<div>
<CopyToClipboard
text={this.state.shortUrl}
copyNode={
<i className="fa fa-clipboard" title={t('Copy to clipboard')} />
}
/>
&nbsp;&nbsp;
<a
href={`mailto:?Subject=${this.props.emailSubject}%20&Body=${emailBody}`}
>
<i className="fa fa-envelope" />
</a>
</div>
}
/>
);
}
}
export default withToasts(URLShortLinkModal);

View File

@@ -24,12 +24,12 @@ import { styled, SupersetClient, t } from '@superset-ui/core';
import { Menu, NoAnimationDropdown } from 'src/common/components';
import Icon from 'src/components/Icon';
import { URL_PARAMS } from 'src/constants';
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
import CssEditor from './CssEditor';
import RefreshIntervalModal from './RefreshIntervalModal';
import SaveModal from './SaveModal';
import injectCustomCss from '../util/injectCustomCss';
import { SAVE_TYPE_NEWDASHBOARD } from '../util/constants';
import URLShortLinkModal from '../../components/URLShortLinkModal';
import FilterScopeModal from './filterscope/FilterScopeModal';
import downloadAsImage from '../../utils/downloadAsImage';
import getDashboardUrl from '../util/getDashboardUrl';
@@ -197,11 +197,18 @@ class HeaderActionsDropdown extends React.PureComponent {
refreshLimit,
refreshWarning,
lastModifiedTime,
addSuccessToast,
addDangerToast,
} = this.props;
const emailTitle = t('Superset dashboard');
const emailSubject = `${emailTitle} ${dashboardTitle}`;
const emailBody = t('Check out this dashboard: ');
const url = getDashboardUrl(
window.location.pathname,
getActiveFilters(),
window.location.hash,
);
const menu = (
<Menu
@@ -234,19 +241,15 @@ class HeaderActionsDropdown extends React.PureComponent {
/>
</Menu.Item>
)}
<Menu.Item key={MENU_KEYS.SHARE_DASHBOARD}>
<URLShortLinkModal
url={getDashboardUrl(
window.location.pathname,
getActiveFilters(),
window.location.hash,
)}
emailSubject={emailSubject}
emailContent={emailBody}
addDangerToast={this.props.addDangerToast}
triggerNode={<span>{t('Share dashboard')}</span>}
/>
</Menu.Item>
<ShareMenuItems
url={url}
copyMenuItemTitle={t('Copy dashboard URL')}
emailMenuItemTitle={t('Share dashboard by email')}
emailSubject={emailSubject}
emailBody={emailBody}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
/>
<Menu.Item
key={MENU_KEYS.REFRESH_DASHBOARD}
data-test="refresh-dashboard-menu-item"

View File

@@ -47,6 +47,7 @@ const propTypes = {
componentId: PropTypes.string.isRequired,
dashboardId: PropTypes.number.isRequired,
filters: PropTypes.object.isRequired,
addSuccessToast: PropTypes.func.isRequired,
addDangerToast: PropTypes.func.isRequired,
handleToggleFullSize: PropTypes.func.isRequired,
chartStatus: PropTypes.string.isRequired,
@@ -98,6 +99,7 @@ class SliceHeader extends React.PureComponent {
annotationError,
componentId,
dashboardId,
addSuccessToast,
addDangerToast,
handleToggleFullSize,
isFullSize,
@@ -157,6 +159,7 @@ class SliceHeader extends React.PureComponent {
sliceCanEdit={sliceCanEdit}
componentId={componentId}
dashboardId={dashboardId}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
handleToggleFullSize={handleToggleFullSize}
isFullSize={isFullSize}

View File

@@ -21,7 +21,7 @@ import PropTypes from 'prop-types';
import moment from 'moment';
import { styled, t } from '@superset-ui/core';
import { Menu, NoAnimationDropdown } from 'src/common/components';
import URLShortLinkModal from '../../components/URLShortLinkModal';
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
import downloadAsImage from '../../utils/downloadAsImage';
import getDashboardUrl from '../util/getDashboardUrl';
import { getActiveFilters } from '../util/activeDashboardFilters';
@@ -173,6 +173,7 @@ class SliceHeaderControls extends React.PureComponent {
cachedDttm,
updatedDttm,
componentId,
addSuccessToast,
addDangerToast,
isFullSize,
} = this.props;
@@ -193,7 +194,7 @@ class SliceHeaderControls extends React.PureComponent {
// If all queries have same cache time we can unit them to one
let refreshTooltip = [...new Set(refreshTooltipData)];
refreshTooltip = refreshTooltip.map((item, index) => (
<div>
<div key={`tooltip-${index}`}>
{refreshTooltip.length > 1
? `${t('Query')} ${index + 1}: ${item}`
: item}
@@ -232,18 +233,18 @@ class SliceHeaderControls extends React.PureComponent {
</Menu.Item>
)}
<Menu.Item key={MENU_KEYS.SHARE_CHART}>
<URLShortLinkModal
url={getDashboardUrl(
window.location.pathname,
getActiveFilters(),
componentId,
)}
addDangerToast={addDangerToast}
title={t('Share chart')}
triggerNode={<span>{t('Share chart')}</span>}
/>
</Menu.Item>
<ShareMenuItems
url={getDashboardUrl(
window.location.pathname,
getActiveFilters(),
componentId,
)}
copyMenuItemTitle={t('Copy chart URL')}
emailMenuItemTitle={t('Share chart by email')}
emailSubject={t('Superset chart')}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
/>
<Menu.Item key={MENU_KEYS.RESIZE_LABEL}>{resizeLabel}</Menu.Item>

View File

@@ -67,6 +67,7 @@ const propTypes = {
supersetCanExplore: PropTypes.bool.isRequired,
supersetCanCSV: PropTypes.bool.isRequired,
sliceCanEdit: PropTypes.bool.isRequired,
addSuccessToast: PropTypes.func.isRequired,
addDangerToast: PropTypes.func.isRequired,
};
@@ -256,6 +257,7 @@ export default class Chart extends React.Component {
supersetCanExplore,
supersetCanCSV,
sliceCanEdit,
addSuccessToast,
addDangerToast,
handleToggleFullSize,
isFullSize,
@@ -306,6 +308,7 @@ export default class Chart extends React.Component {
componentId={componentId}
dashboardId={dashboardId}
filters={filters}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
handleToggleFullSize={handleToggleFullSize}
isFullSize={isFullSize}

View File

@@ -0,0 +1,85 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF 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 from 'react';
import { useUrlShortener } from 'src/common/hooks/useUrlShortener';
import copyTextToClipboard from 'src/utils/copy';
import { t } from '@superset-ui/core';
import { Menu } from 'src/common/components';
interface ShareMenuItemProps {
url: string;
copyMenuItemTitle: string;
emailMenuItemTitle: string;
emailSubject: string;
emailBody: string;
addDangerToast: Function;
addSuccessToast: Function;
}
const ShareMenuItems = (props: ShareMenuItemProps) => {
const {
url,
copyMenuItemTitle,
emailMenuItemTitle,
emailSubject,
emailBody,
addDangerToast,
addSuccessToast,
...rest
} = props;
const getShortUrl = useUrlShortener(url);
async function onCopyLink() {
try {
const shortUrl = await getShortUrl();
await copyTextToClipboard(shortUrl);
addSuccessToast(t('Copied to clipboard!'));
} catch (error) {
addDangerToast(t('Sorry, your browser does not support copying.'));
}
}
async function onShareByEmail() {
try {
const shortUrl = await getShortUrl();
const bodyWithLink = `${emailBody}${shortUrl}`;
window.location.href = `mailto:?Subject=${emailSubject}%20&Body=${bodyWithLink}`;
} catch (error) {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
}
return (
<>
<Menu.Item key="copy-url" {...rest}>
<div onClick={onCopyLink} role="button" tabIndex={0}>
{copyMenuItemTitle}
</div>
</Menu.Item>
<Menu.Item key="share-by-email" {...rest}>
<div onClick={onShareByEmail} role="button" tabIndex={0}>
{emailMenuItemTitle}
</div>
</Menu.Item>
</>
);
};
export default ShareMenuItems;

View File

@@ -26,7 +26,7 @@ import {
} from '../actions/dashboardState';
import { updateComponents } from '../actions/dashboardLayout';
import { changeFilter } from '../actions/dashboardFilters';
import { addDangerToast } from '../../messageToasts/actions';
import { addSuccessToast, addDangerToast } from '../../messageToasts/actions';
import { refreshChart } from '../../chart/chartAction';
import { logEvent } from '../../logger/actions';
import {
@@ -89,6 +89,7 @@ function mapDispatchToProps(dispatch) {
return bindActionCreators(
{
updateComponents,
addSuccessToast,
addDangerToast,
toggleExpandSlice,
changeFilter,

View File

@@ -22,6 +22,8 @@ import { t } from '@superset-ui/core';
import Popover from 'src/common/components/Popover';
import FormLabel from 'src/components/FormLabel';
import Icon from 'src/components/Icon';
import { Tooltip } from 'src/common/components/Tooltip';
import CopyToClipboard from 'src/components/CopyToClipboard';
import { getShortUrl } from 'src/utils/urlUtils';
import { URL_PARAMS } from 'src/constants';
@@ -150,10 +152,20 @@ export default class EmbedCodeButton extends React.Component {
onClick={this.getCopyUrl}
content={this.renderPopoverContent()}
>
<span className="btn btn-default btn-sm" data-test="embed-code-button">
<i className="fa fa-code" />
&nbsp;
</span>
<Tooltip
id="embed-code-tooltip"
placement="top"
title="Embed code"
trigger={['hover']}
>
<div
className="btn btn-default btn-sm"
data-test="embed-code-button"
style={{ height: 30 }}
>
<Icon name="code" width={15} height={15} style={{ marginTop: 1 }} />
</div>
</Tooltip>
</Popover>
);
}

View File

@@ -1,120 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF 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 from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import { t } from '@superset-ui/core';
import URLShortLinkButton from '../../components/URLShortLinkButton';
import EmbedCodeButton from './EmbedCodeButton';
import ConnectedDisplayQueryButton from './DisplayQueryButton';
import { exportChart, getExploreLongUrl } from '../exploreUtils';
const propTypes = {
actions: PropTypes.object.isRequired,
canDownload: PropTypes.oneOfType([PropTypes.string, PropTypes.bool])
.isRequired,
chartStatus: PropTypes.string,
chartHeight: PropTypes.string.isRequired,
latestQueryFormData: PropTypes.object,
queriesResponse: PropTypes.arrayOf(PropTypes.object),
slice: PropTypes.object,
};
export default function ExploreActionButtons({
actions,
canDownload,
chartHeight,
chartStatus,
latestQueryFormData,
queriesResponse,
slice,
}) {
const exportToCSVClasses = cx('btn btn-default btn-sm', {
disabled: !canDownload,
});
const doExportCSV = exportChart.bind(this, {
formData: latestQueryFormData,
resultType: 'results',
resultFormat: 'csv',
});
const doExportChart = exportChart.bind(this, {
formData: latestQueryFormData,
resultType: 'results',
resultFormat: 'json',
});
return (
<div
className="btn-group results"
role="group"
data-test="btn-group-results"
>
{latestQueryFormData && (
<URLShortLinkButton
url={getExploreLongUrl(latestQueryFormData)}
emailSubject="Superset Chart"
emailContent="Check out this chart: "
/>
)}
{latestQueryFormData && (
<EmbedCodeButton latestQueryFormData={latestQueryFormData} />
)}
{latestQueryFormData && (
<div
role="button"
tabIndex={0}
onClick={doExportChart}
className="btn btn-default btn-sm"
title={t('Export to .json')}
target="_blank"
rel="noopener noreferrer"
>
<i className="fa fa-file-code-o" /> .json
</div>
)}
{latestQueryFormData && (
<div
role="button"
tabIndex={0}
onClick={doExportCSV}
className={exportToCSVClasses}
title={t('Export to .csv format')}
target="_blank"
rel="noopener noreferrer"
>
<i className="fa fa-file-text-o" /> .csv
</div>
)}
<ConnectedDisplayQueryButton
chartHeight={chartHeight}
queryResponse={queriesResponse?.[0]}
latestQueryFormData={latestQueryFormData}
chartStatus={chartStatus}
onOpenInEditor={actions.redirectSQLLab}
onOpenPropertiesModal={actions.openPropertiesModal}
slice={slice}
/>
</div>
);
}
ExploreActionButtons.propTypes = propTypes;

View File

@@ -0,0 +1,204 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF 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, { useState } from 'react';
import cx from 'classnames';
import { t } from '@superset-ui/core';
import Icon from 'src/components/Icon';
import { Tooltip } from 'src/common/components/Tooltip';
import copyTextToClipboard from 'src/utils/copy';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import { useUrlShortener } from 'src/common/hooks/useUrlShortener';
import EmbedCodeButton from './EmbedCodeButton';
import ConnectedDisplayQueryButton from './DisplayQueryButton';
import { exportChart, getExploreLongUrl } from '../exploreUtils';
type ActionButtonProps = {
icon: React.ReactElement;
text?: string;
tooltip: string;
className?: string;
onClick: React.MouseEventHandler<HTMLElement>;
onTooltipVisibilityChange?: (visible: boolean) => void;
'data-test'?: string;
};
type ExploreActionButtonsProps = {
actions: { redirectSQLLab: Function; openPropertiesModal: Function };
canDownload: boolean;
chartHeight: number;
chartStatus: string;
latestQueryFormData: {};
queriesResponse: {};
slice: { slice_name: string };
addDangerToast: Function;
};
const ActionButton = (props: ActionButtonProps) => {
const {
icon,
text,
tooltip,
className,
onTooltipVisibilityChange,
...rest
} = props;
return (
<Tooltip
id={`${icon}-tooltip`}
placement="top"
title={tooltip}
trigger={['hover']}
onVisibleChange={onTooltipVisibilityChange}
>
<div
role="button"
tabIndex={0}
css={{ '&:focus, &:focus:active': { outline: 0 } }}
className={className || 'btn btn-default btn-sm'}
style={{ height: 30 }}
{...rest}
>
{icon}
{text && <span style={{ marginLeft: 5 }}>{text}</span>}
</div>
</Tooltip>
);
};
const ExploreActionButtons = (props: ExploreActionButtonsProps) => {
const {
actions,
canDownload,
chartHeight,
chartStatus,
latestQueryFormData,
queriesResponse,
slice,
addDangerToast,
} = props;
const copyTooltipText = t('Copy chart URL to clipboard');
const [copyTooltip, setCopyTooltip] = useState(copyTooltipText);
const longUrl = getExploreLongUrl(latestQueryFormData);
const getShortUrl = useUrlShortener(longUrl);
const doCopyLink = async () => {
try {
setCopyTooltip(t('Loading...'));
const shortUrl = await getShortUrl();
await copyTextToClipboard(shortUrl);
setCopyTooltip(t('Copied to clipboard!'));
} catch (error) {
setCopyTooltip(t('Sorry, your browser does not support copying.'));
}
};
const doShareEmail = async () => {
try {
const subject = t('Superset Chart');
const shortUrl = await getShortUrl();
const body = t('%s%s', 'Check out this chart: ', shortUrl);
window.location.href = `mailto:?Subject=${subject}%20&Body=${body}`;
} catch (error) {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
};
const doExportCSV = exportChart.bind(this, {
formData: latestQueryFormData,
resultType: 'results',
resultFormat: 'csv',
});
const doExportChart = exportChart.bind(this, {
formData: latestQueryFormData,
resultType: 'results',
resultFormat: 'json',
});
const exportToCSVClasses = cx('btn btn-default btn-sm', {
disabled: !canDownload,
});
return (
<div
className="btn-group results"
role="group"
data-test="btn-group-results"
>
{latestQueryFormData && (
<>
<ActionButton
icon={
<Icon
name="link"
width={15}
height={15}
style={{ marginTop: 1 }}
/>
}
tooltip={copyTooltip}
onClick={doCopyLink}
data-test="short-link-button"
onTooltipVisibilityChange={value =>
!value && setTimeout(() => setCopyTooltip(copyTooltipText), 200)
}
/>
<ActionButton
icon={
<Icon
name="email"
width={15}
height={15}
style={{ marginTop: 1 }}
/>
}
tooltip={t('Share chart by email')}
onClick={doShareEmail}
/>
<EmbedCodeButton latestQueryFormData={latestQueryFormData} />
<ActionButton
icon={<i className="fa fa-file-code-o" />}
text=".JSON"
tooltip={t('Export to .JSON format')}
onClick={doExportChart}
/>
<ActionButton
icon={<i className="fa fa-file-text-o" />}
text=".CSV"
tooltip={t('Export to .CSV format')}
onClick={doExportCSV}
className={exportToCSVClasses}
/>
</>
)}
<ConnectedDisplayQueryButton
chartHeight={chartHeight}
queryResponse={queriesResponse?.[0]}
latestQueryFormData={latestQueryFormData}
chartStatus={chartStatus}
onOpenInEditor={actions.redirectSQLLab}
onOpenPropertiesModal={actions.openPropertiesModal}
slice={slice}
/>
</div>
);
};
export default withToasts(ExploreActionButtons);

View File

@@ -17,8 +17,8 @@
* under the License.
*/
const copyTextToClipboard = (text: string) =>
new Promise((resolve, reject) => {
const copyTextToClipboard = async (text: string) =>
new Promise<void>((resolve, reject) => {
const selection: Selection | null = document.getSelection();
if (selection) {
selection.removeAllRanges();

View File

@@ -142,10 +142,10 @@ function ChartTable({
buttons={[
{
name: (
<div>
<>
<i className="fa fa-plus" />
{t('Chart')}
</div>
</>
),
buttonStyle: 'tertiary',
onClick: () => {

View File

@@ -148,9 +148,10 @@ function DashboardTable({
buttons={[
{
name: (
<div>
<i className="fa fa-plus" /> Dashboard{' '}
</div>
<>
<i className="fa fa-plus" />
Dashboard
</>
),
buttonStyle: 'tertiary',
onClick: () => {

View File

@@ -267,10 +267,10 @@ const SavedQueries = ({
buttons={[
{
name: (
<div>
<>
<i className="fa fa-plus" />
SQL Query{' '}
</div>
SQL Query
</>
),
buttonStyle: 'tertiary',
onClick: () => {