feat(explore): Move chart actions into dropdown (#19446)

* feat(explore): Move chart actions to a dropdown menu

* Fix tests and add some new ones

* Add background color to embed code button

* Fix cypress tests

* Move copy permalink to actions menu

* Remove unused function

* Move share by email above embed code

* Fix test

* Fix test

* Fix test

* Fix test

* Fix test
This commit is contained in:
Kamil Gabryjelski
2022-03-31 20:41:15 +02:00
committed by GitHub
parent 85e330e94b
commit 1a1322d3d9
16 changed files with 908 additions and 854 deletions

View File

@@ -38,7 +38,7 @@ describe('Test explore links', () => {
cy.visitChartByName('Growth Rate');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('div#query').click();
cy.get('[aria-label="Menu actions trigger"]').click();
cy.get('span').contains('View query').parent().click();
cy.wait('@chartData').then(() => {
cy.get('code');
@@ -52,7 +52,12 @@ describe('Test explore links', () => {
cy.visitChartByName('Growth Rate');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test=embed-code-button]').click();
cy.get('[aria-label="Menu actions trigger"]').click();
cy.get('div[title="Share"]').trigger('mouseover');
// need to use [id= syntax, otherwise error gets triggered because of special character in id
cy.get('[id="share_submenu$Menu"]').within(() => {
cy.contains('Embed code').parent().click();
});
cy.get('#embed-code-popover').within(() => {
cy.get('textarea[name=embedCode]').contains('iframe');
});

View File

@@ -39,6 +39,7 @@ const propTypes = {
resizableConfig: PropTypes.object,
draggable: PropTypes.bool,
draggableConfig: PropTypes.object,
destroyOnClose: PropTypes.bool,
};
const defaultProps = {
@@ -89,6 +90,7 @@ export default class ModalTrigger extends React.Component {
resizableConfig={this.props.resizableConfig}
draggable={this.props.draggable}
draggableConfig={this.props.draggableConfig}
destroyOnClose={this.props.destroyOnClose}
>
{this.props.modalBody}
</Modal>

View File

@@ -26,8 +26,7 @@ import React, {
} from 'react';
import { t, SupersetTheme } from '@superset-ui/core';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import { bindActionCreators } from 'redux';
import { connect, useDispatch, useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { addReport, editReport } from 'src/reports/actions/reports';
import { AlertObject } from 'src/views/CRUD/alert/types';
@@ -85,6 +84,7 @@ interface ChartObject {
chartUpdateEndTime: number;
chartUpdateStartTime: number;
latestQueryFormData: object;
sliceFormData: Record<string, any>;
queryController: { abort: () => {} };
queriesResponse: object;
triggerQuery: boolean;
@@ -92,7 +92,6 @@ interface ChartObject {
}
interface ReportProps {
addReport: (report?: ReportObject) => {};
onHide: () => {};
onReportAdd: (report?: ReportObject) => {};
addDangerToast: (msg: string) => void;
@@ -102,7 +101,6 @@ interface ReportProps {
dashboardId?: number;
chart?: ChartObject;
creationMethod: string;
props: any;
}
interface ReportPayloadType {
@@ -189,8 +187,8 @@ const ReportModal: FunctionComponent<ReportProps> = ({
show = false,
...props
}) => {
const vizType = props.props.chart?.sliceFormData?.viz_type;
const isChart = !!props.props.chart;
const vizType = props.chart?.sliceFormData?.viz_type;
const isChart = !!props.chart;
const defaultNotificationFormat =
isChart && TEXT_BASED_VISUALIZATION_TYPES.includes(vizType)
? NOTIFICATION_FORMATS.TEXT
@@ -226,19 +224,19 @@ const ReportModal: FunctionComponent<ReportProps> = ({
// Create new Report
const newReportValues: Partial<ReportObject> = {
crontab: currentReport?.crontab,
dashboard: props.props.dashboardId,
chart: props.props.chart?.id,
dashboard: props.dashboardId,
chart: props.chart?.id,
description: currentReport?.description,
name: currentReport?.name,
owners: [props.props.userId],
owners: [props.userId],
recipients: [
{
recipient_config_json: { target: props.props.userEmail },
recipient_config_json: { target: props.userEmail },
type: 'Email',
},
],
type: 'Report',
creation_method: props.props.creationMethod,
creation_method: props.creationMethod,
active: true,
report_format: currentReport?.report_format || defaultNotificationFormat,
timezone: currentReport?.timezone,
@@ -416,7 +414,4 @@ const ReportModal: FunctionComponent<ReportProps> = ({
);
};
const mapDispatchToProps = (dispatch: any) =>
bindActionCreators({ addReport, editReport }, dispatch);
export default connect(null, mapDispatchToProps)(withToasts(ReportModal));
export default withToasts(ReportModal);

View File

@@ -662,12 +662,10 @@ class Header extends React.PureComponent {
<ReportModal
show={this.state.showingReportModal}
onHide={this.hideReportModal}
props={{
userId: user.userId,
userEmail: user.email,
dashboardId: dashboardInfo.id,
creationMethod: 'dashboards',
}}
userId={user.userId}
userEmail={user.email}
dashboardId={dashboardInfo.id}
creationMethod="dashboards"
/>
)}

View File

@@ -1,168 +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 Popover from 'src/components/Popover';
import { FormLabel } from 'src/components/Form';
import Icons from 'src/components/Icons';
import { Tooltip } from 'src/components/Tooltip';
import CopyToClipboard from 'src/components/CopyToClipboard';
import { URL_PARAMS } from 'src/constants';
import { getChartPermalink } from 'src/utils/urlUtils';
export default class EmbedCodeButton extends React.Component {
constructor(props) {
super(props);
this.state = {
height: '400',
width: '600',
url: '',
errorMessage: '',
};
this.handleInputChange = this.handleInputChange.bind(this);
this.updateUrl = this.updateUrl.bind(this);
}
handleInputChange(e) {
const { value, name } = e.currentTarget;
const data = {};
data[name] = value;
this.setState(data);
}
updateUrl() {
this.setState({ url: '' });
getChartPermalink(this.props.formData)
.then(url => this.setState({ errorMessage: '', url }))
.catch(() => {
this.setState({ errorMessage: t('Error') });
this.props.addDangerToast(
t('Sorry, something went wrong. Try again later.'),
);
});
}
generateEmbedHTML() {
if (!this.state.url) return '';
const srcLink = `${this.state.url}?${URL_PARAMS.standalone.name}=1&height=${this.state.height}`;
return (
'<iframe\n' +
` width="${this.state.width}"\n` +
` height="${this.state.height}"\n` +
' seamless\n' +
' frameBorder="0"\n' +
' scrolling="no"\n' +
` src="${srcLink}"\n` +
'>\n' +
'</iframe>'
);
}
renderPopoverContent() {
const html = this.generateEmbedHTML();
const text =
this.state.errorMessage || html || t('Generating link, please wait..');
return (
<div id="embed-code-popover" data-test="embed-code-popover">
<div className="row">
<div className="col-sm-10">
<textarea
data-test="embed-code-textarea"
name="embedCode"
disabled={!html}
value={text}
rows="4"
readOnly
className="form-control input-sm"
style={{ resize: 'vertical' }}
/>
</div>
<div className="col-sm-2">
<CopyToClipboard
shouldShowText={false}
text={html}
copyNode={
<i className="fa fa-clipboard" title={t('Copy to clipboard')} />
}
/>
</div>
</div>
<br />
<div className="row">
<div className="col-md-6 col-sm-12">
<div className="form-group">
<small>
<FormLabel htmlFor="embed-height">{t('Height')}</FormLabel>
</small>
<input
className="form-control input-sm"
type="text"
defaultValue={this.state.height}
name="height"
onChange={this.handleInputChange}
/>
</div>
</div>
<div className="col-md-6 col-sm-12">
<div className="form-group">
<small>
<FormLabel htmlFor="embed-width">{t('Width')}</FormLabel>
</small>
<input
className="form-control input-sm"
type="text"
defaultValue={this.state.width}
name="width"
onChange={this.handleInputChange}
id="embed-width"
/>
</div>
</div>
</div>
</div>
);
}
render() {
return (
<Popover
trigger="click"
placement="left"
onClick={this.updateUrl}
content={this.renderPopoverContent()}
>
<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={{ display: 'flex', alignItems: 'center', height: 30 }}
>
<Icons.Code iconSize="l" />
</div>
</Tooltip>
</Popover>
);
}
}

View File

@@ -1,62 +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 { shallow } from 'enzyme';
import { styledMount as mount } from 'spec/helpers/theming';
import Popover from 'src/components/Popover';
import EmbedCodeButton from 'src/explore/components/EmbedCodeButton';
import { DashboardStandaloneMode } from 'src/dashboard/util/constants';
describe('EmbedCodeButton', () => {
it('renders', () => {
expect(React.isValidElement(<EmbedCodeButton />)).toBe(true);
});
it('renders overlay trigger', () => {
const wrapper = shallow(<EmbedCodeButton />);
expect(wrapper.find(Popover)).toExist();
});
it('returns correct embed code', () => {
const wrapper = mount(
<EmbedCodeButton formData={{}} addDangerToast={() => {}} />,
);
const url = 'http://localhost/explore/p/100';
wrapper.find(EmbedCodeButton).setState({
height: '1000',
width: '2000',
url,
});
const embedHTML =
`${
'<iframe\n' +
' width="2000"\n' +
' height="1000"\n' +
' seamless\n' +
' frameBorder="0"\n' +
' scrolling="no"\n' +
` src="${url}?standalone=`
}${DashboardStandaloneMode.HIDE_NAV}&height=1000"\n` +
`>\n` +
`</iframe>`;
expect(wrapper.find(EmbedCodeButton).instance().generateEmbedHTML()).toBe(
embedHTML,
);
});
});

View File

@@ -0,0 +1,153 @@
/**
* 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, { useCallback, useEffect, useMemo, useState } from 'react';
import { css, styled, t } from '@superset-ui/core';
import { Input, TextArea } from 'src/components/Input';
import CopyToClipboard from 'src/components/CopyToClipboard';
import { URL_PARAMS } from 'src/constants';
import { getChartPermalink } from 'src/utils/urlUtils';
import { CopyButton } from './DataTableControl';
const CopyButtonEmbedCode = styled(CopyButton)`
&& {
margin: 0 0 ${({ theme }) => theme.gridUnit}px;
}
`;
const EmbedCodeContent = ({ formData, addDangerToast }) => {
const [height, setHeight] = useState('400');
const [width, setWidth] = useState('600');
const [url, setUrl] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const handleInputChange = useCallback(e => {
const { value, name } = e.currentTarget;
if (name === 'width') {
setWidth(value);
}
if (name === 'height') {
setHeight(value);
}
}, []);
const updateUrl = useCallback(() => {
setUrl('');
getChartPermalink(formData)
.then(url => {
setUrl(url);
setErrorMessage('');
})
.catch(() => {
setErrorMessage(t('Error'));
addDangerToast(t('Sorry, something went wrong. Try again later.'));
});
}, [addDangerToast, formData]);
useEffect(() => {
updateUrl();
}, []);
const html = useMemo(() => {
if (!url) return '';
const srcLink = `${url}?${URL_PARAMS.standalone.name}=1&height=${height}`;
return (
'<iframe\n' +
` width="${width}"\n` +
` height="${height}"\n` +
' seamless\n' +
' frameBorder="0"\n' +
' scrolling="no"\n' +
` src="${srcLink}"\n` +
'>\n' +
'</iframe>'
);
}, [height, url, width]);
const text = errorMessage || html || t('Generating link, please wait..');
return (
<div id="embed-code-popover" data-test="embed-code-popover">
<div
css={css`
display: flex;
flex-direction: column;
`}
>
<CopyToClipboard
shouldShowText={false}
text={html}
copyNode={
<CopyButtonEmbedCode buttonSize="xsmall">
<i className="fa fa-clipboard" />
</CopyButtonEmbedCode>
}
/>
<TextArea
data-test="embed-code-textarea"
name="embedCode"
disabled={!html}
value={text}
rows="4"
readOnly
css={theme => css`
resize: vertical;
padding: ${theme.gridUnit * 2}px;
font-size: ${theme.typography.sizes.s}px;
border-radius: 4px;
background-color: ${theme.colors.secondary.light5};
`}
/>
</div>
<div
css={theme => css`
display: flex;
margin-top: ${theme.gridUnit * 4}px;
& > div {
margin-right: ${theme.gridUnit * 2}px;
}
& > div:last-of-type {
margin-right: 0;
margin-left: ${theme.gridUnit * 2}px;
}
`}
>
<div>
<label htmlFor="embed-height">{t('Chart height')}</label>
<Input
type="text"
defaultValue={height}
name="height"
onChange={handleInputChange}
/>
</div>
<div>
<label htmlFor="embed-width">{t('Chart width')}</label>
<Input
type="text"
defaultValue={width}
name="width"
onChange={handleInputChange}
id="embed-width"
/>
</div>
</div>
</div>
);
};
export default EmbedCodeContent;

View File

@@ -0,0 +1,48 @@
/**
* 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 fetchMock from 'fetch-mock';
import { render, screen } from 'spec/helpers/testing-library';
import EmbedCodeContent from 'src/explore/components/EmbedCodeContent';
const url = 'http://localhost/explore/p/100';
fetchMock.post('glob:*/api/v1/explore/permalink', { url });
describe('EmbedCodeButton', () => {
it('renders', () => {
expect(React.isValidElement(<EmbedCodeContent />)).toBe(true);
});
it('returns correct embed code', async () => {
render(<EmbedCodeContent />, { useRedux: true });
expect(await screen.findByText('iframe', { exact: false })).toBeVisible();
expect(await screen.findByText('/iframe', { exact: false })).toBeVisible();
expect(
await screen.findByText('width="600"', { exact: false }),
).toBeVisible();
expect(
await screen.findByText('height="400"', { exact: false }),
).toBeVisible();
expect(
await screen.findByText(`src="${url}?standalone=1&height=400"`, {
exact: false,
}),
).toBeVisible();
});
});

View File

@@ -1,117 +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 { shallow, mount } from 'enzyme';
import { mockStore } from 'spec/fixtures/mockStore';
import ExploreActionButtons from 'src/explore/components/ExploreActionButtons';
import * as exploreUtils from 'src/explore/exploreUtils';
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
import { Provider } from 'react-redux';
import sinon from 'sinon';
describe('ExploreActionButtons', () => {
const defaultProps = {
actions: {},
canDownloadCSV: 'True',
latestQueryFormData: {},
queryEndpoint: 'localhost',
chartHeight: '30px',
};
it('renders', () => {
expect(
React.isValidElement(<ExploreActionButtons {...defaultProps} />),
).toBe(true);
});
it('should render 6 children/buttons', () => {
const wrapper = shallow(
<ExploreActionButtons {...defaultProps} store={mockStore} />,
);
expect(wrapper.dive().children()).toHaveLength(6);
});
describe('ExploreActionButtons and no permission to download CSV', () => {
let wrapper;
const defaultProps = {
actions: {},
canDownloadCSV: false,
latestQueryFormData: {},
queryEndpoint: 'localhost',
chartHeight: '30px',
};
beforeEach(() => {
wrapper = mount(
<ThemeProvider theme={supersetTheme}>
<ExploreActionButtons {...defaultProps} />
</ThemeProvider>,
{
wrappingComponent: Provider,
wrappingComponentProps: {
store: mockStore,
},
},
);
});
it('should render csv buttons but is disabled and not clickable', () => {
const spyExportChart = sinon.spy(exploreUtils, 'exportChart');
const csvButton = wrapper.find('div.disabled');
expect(wrapper).toHaveLength(1);
csvButton.simulate('click');
expect(spyExportChart.callCount).toBe(0);
spyExportChart.restore();
});
});
describe('Dropdown csv button when viz type is pivot table', () => {
let wrapper;
const defaultProps = {
actions: {},
canDownloadCSV: false,
latestQueryFormData: { viz_type: 'pivot_table_v2' },
queryEndpoint: 'localhost',
chartHeight: '30px',
};
beforeEach(() => {
wrapper = mount(
<ThemeProvider theme={supersetTheme}>
<ExploreActionButtons {...defaultProps} />
</ThemeProvider>,
{
wrappingComponent: Provider,
wrappingComponentProps: {
store: mockStore,
},
},
);
});
it('should render a dropdown button when viz type is pivot table', () => {
const csvTrigger = wrapper.find(
'div[role="button"] span[aria-label="caret-down"]',
);
expect(csvTrigger).toExist();
});
});
});

View File

@@ -1,234 +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, { ReactElement, useState } from 'react';
import cx from 'classnames';
import { QueryFormData, t } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { Tooltip } from 'src/components/Tooltip';
import { Slice } from 'src/types/Chart';
import copyTextToClipboard from 'src/utils/copy';
import { getChartPermalink } from 'src/utils/urlUtils';
import withToasts from 'src/components/MessageToasts/withToasts';
import EmbedCodeButton from './EmbedCodeButton';
import { exportChart } from '../exploreUtils';
import ExploreAdditionalActionsMenu from './ExploreAdditionalActionsMenu';
import { ExportToCSVDropdown } from './ExportToCSVDropdown';
type ActionButtonProps = {
prefixIcon: React.ReactElement;
suffixIcon?: React.ReactElement;
text?: string | ReactElement;
tooltip: string;
className?: string;
onClick?: React.MouseEventHandler<HTMLElement>;
onTooltipVisibilityChange?: (visible: boolean) => void;
'data-test'?: string;
};
type ExploreActionButtonsProps = {
actions: { redirectSQLLab: () => void; openPropertiesModal: () => void };
canDownloadCSV: boolean;
chartStatus: string;
latestQueryFormData: QueryFormData;
queriesResponse: {};
slice: Slice;
addDangerToast: Function;
addSuccessToast: Function;
};
const VIZ_TYPES_PIVOTABLE = ['pivot_table', 'pivot_table_v2'];
const ActionButton = (props: ActionButtonProps) => {
const {
prefixIcon,
suffixIcon,
text,
tooltip,
className,
onTooltipVisibilityChange,
...rest
} = props;
return (
<Tooltip
id={`${prefixIcon}-tooltip`}
placement="top"
title={tooltip}
trigger={['hover']}
onVisibleChange={onTooltipVisibilityChange}
>
<div
role="button"
tabIndex={0}
css={{
display: 'flex',
alignItems: 'center',
'&:focus, &:focus:active': { outline: 0 },
}}
className={className || 'btn btn-default btn-sm'}
style={{ height: 30 }}
{...rest}
>
{prefixIcon}
{text && <span style={{ marginLeft: 5 }}>{text}</span>}
{suffixIcon}
</div>
</Tooltip>
);
};
const ExploreActionButtons = (props: ExploreActionButtonsProps) => {
const {
actions,
canDownloadCSV,
chartStatus,
latestQueryFormData,
slice,
addDangerToast,
addSuccessToast,
} = props;
const copyTooltipText = t('Copy permalink to clipboard');
const [copyTooltip, setCopyTooltip] = useState(copyTooltipText);
const doCopyLink = async () => {
try {
setCopyTooltip(t('Loading...'));
const url = await getChartPermalink(latestQueryFormData);
await copyTextToClipboard(url);
setCopyTooltip(t('Copied to clipboard!'));
addSuccessToast(t('Copied to clipboard!'));
} catch (error) {
setCopyTooltip(t('Copying permalink failed.'));
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
};
const doShareEmail = async () => {
try {
const subject = t('Superset Chart');
const url = await getChartPermalink(latestQueryFormData);
const body = encodeURIComponent(t('%s%s', 'Check out this chart: ', url));
window.location.href = `mailto:?Subject=${subject}%20&Body=${body}`;
} catch (error) {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
};
const doExportCSV = canDownloadCSV
? exportChart.bind(this, {
formData: latestQueryFormData,
resultType: 'full',
resultFormat: 'csv',
})
: null;
const doExportCSVPivoted = canDownloadCSV
? exportChart.bind(this, {
formData: latestQueryFormData,
resultType: 'post_processed',
resultFormat: 'csv',
})
: null;
const doExportJson = exportChart.bind(this, {
formData: latestQueryFormData,
resultType: 'results',
resultFormat: 'json',
});
const exportToCSVClasses = cx('btn btn-default btn-sm', {
disabled: !canDownloadCSV,
});
return (
<div
className="btn-group results"
role="group"
data-test="btn-group-results"
>
{latestQueryFormData && (
<>
<ActionButton
prefixIcon={<Icons.Link iconSize="l" />}
tooltip={copyTooltip}
onClick={doCopyLink}
data-test="short-link-button"
onTooltipVisibilityChange={value =>
!value && setTimeout(() => setCopyTooltip(copyTooltipText), 200)
}
/>
<ActionButton
prefixIcon={<Icons.Email iconSize="l" />}
tooltip={t('Share permalink by email')}
onClick={doShareEmail}
/>
<EmbedCodeButton
formData={latestQueryFormData}
addDangerToast={addDangerToast}
/>
<ActionButton
prefixIcon={<Icons.FileTextOutlined iconSize="m" />}
text=".JSON"
tooltip={t('Export to .JSON format')}
onClick={doExportJson}
/>
{VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type) ? (
<ExportToCSVDropdown
exportCSVOriginal={doExportCSV}
exportCSVPivoted={doExportCSVPivoted}
>
<ActionButton
prefixIcon={<Icons.FileExcelOutlined iconSize="m" />}
suffixIcon={
<Icons.CaretDown
iconSize="l"
css={theme => `
margin-left: ${theme.gridUnit}px;
margin-right: ${-theme.gridUnit}px;
`}
/>
}
text=".CSV"
tooltip={t('Export to .CSV format')}
className={exportToCSVClasses}
/>
</ExportToCSVDropdown>
) : (
<ActionButton
prefixIcon={<Icons.FileExcelOutlined iconSize="m" />}
text=".CSV"
tooltip={t('Export to .CSV format')}
onClick={doExportCSV}
className={exportToCSVClasses}
/>
)}
</>
)}
<ExploreAdditionalActionsMenu
latestQueryFormData={latestQueryFormData}
chartStatus={chartStatus}
onOpenInEditor={actions.redirectSQLLab}
onOpenPropertiesModal={actions.openPropertiesModal}
slice={slice}
/>
</div>
);
};
export default withToasts(ExploreActionButtons);

View File

@@ -1,60 +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 { shallow } from 'enzyme';
import { styledMount as mount } from 'spec/helpers/theming';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import { AntdDropdown } from 'src/components';
import { Menu } from 'src/components/Menu';
import ExploreAdditionalActionsMenu from 'src/explore/components/ExploreAdditionalActionsMenu';
const mockStore = configureStore([thunk]);
const store = mockStore({});
describe('ExploreAdditionalActionsMenu', () => {
const defaultProps = {
animation: false,
queryResponse: {
query: 'SELECT * FROM foo',
language: 'sql',
},
chartStatus: 'success',
queryEndpoint: 'localhost',
latestQueryFormData: {
datasource: '1__table',
},
chartHeight: '30px',
};
it('is valid', () => {
expect(
React.isValidElement(<ExploreAdditionalActionsMenu {...defaultProps} />),
).toBe(true);
});
it('renders a dropdown with 3 items', () => {
const wrapper = mount(
<ExploreAdditionalActionsMenu store={store} {...defaultProps} />,
);
const dropdown = wrapper.find(AntdDropdown);
const menu = shallow(<div>{dropdown.prop('overlay')}</div>);
const menuItems = menu.find(Menu.Item);
expect(menuItems).toHaveLength(3);
});
});

View File

@@ -18,11 +18,13 @@
*/
import React from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import sinon from 'sinon';
import userEvent from '@testing-library/user-event';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import * as chartAction from 'src/components/Chart/chartAction';
import * as downloadAsImage from 'src/utils/downloadAsImage';
import fetchMock from 'fetch-mock';
import * as exploreUtils from 'src/explore/exploreUtils';
import ExploreAdditionalActionsMenu from '.';
const createProps = () => ({
@@ -78,6 +80,8 @@ const createProps = () => ({
chartStatus: 'rendered',
onOpenPropertiesModal: jest.fn(),
onOpenInEditor: jest.fn(),
canAddReports: false,
canDownloadCSV: false,
});
fetchMock.post(
@@ -106,28 +110,105 @@ test('Should open a menu', () => {
expect(props.onOpenInEditor).toBeCalledTimes(0);
expect(props.onOpenPropertiesModal).toBeCalledTimes(0);
expect(
screen.getByRole('menuitem', { name: 'Edit properties' }),
).toBeInTheDocument();
expect(
screen.getByRole('menuitem', { name: 'View query' }),
).toBeInTheDocument();
expect(
screen.getByRole('menuitem', { name: 'Run in SQL Lab' }),
).toBeInTheDocument();
expect(
screen.getByRole('menuitem', { name: 'Download as image' }),
).toBeInTheDocument();
expect(screen.getByText('Edit chart properties')).toBeInTheDocument();
expect(screen.getByText('Download')).toBeInTheDocument();
expect(screen.getByText('Share')).toBeInTheDocument();
expect(screen.getByText('View query')).toBeInTheDocument();
expect(screen.getByText('Run in SQL Lab')).toBeInTheDocument();
expect(screen.queryByText('Set up an email report')).not.toBeInTheDocument();
expect(screen.queryByText('Manage email report')).not.toBeInTheDocument();
});
test('Should call onOpenPropertiesModal when click on "Edit properties"', () => {
test('Menu has email report item if user can add report', () => {
const props = createProps();
props.canAddReports = true;
render(<ExploreAdditionalActionsMenu {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByRole('button'));
expect(screen.queryByText('Manage email report')).not.toBeInTheDocument();
expect(screen.getByText('Set up an email report')).toBeInTheDocument();
});
test('Should open download submenu', async () => {
const props = createProps();
render(<ExploreAdditionalActionsMenu {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByRole('button'));
expect(screen.queryByText('Export to .CSV')).not.toBeInTheDocument();
expect(screen.queryByText('Export to .JSON')).not.toBeInTheDocument();
expect(screen.queryByText('Download as image')).not.toBeInTheDocument();
expect(screen.getByText('Download')).toBeInTheDocument();
userEvent.hover(screen.getByText('Download'));
expect(await screen.findByText('Export to .CSV')).toBeInTheDocument();
expect(await screen.findByText('Export to .JSON')).toBeInTheDocument();
expect(await screen.findByText('Download as image')).toBeInTheDocument();
});
test('Should open share submenu', async () => {
const props = createProps();
render(<ExploreAdditionalActionsMenu {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByRole('button'));
expect(
screen.queryByText('Copy permalink to clipboard'),
).not.toBeInTheDocument();
expect(screen.queryByText('Embed code')).not.toBeInTheDocument();
expect(screen.queryByText('Share chart by email')).not.toBeInTheDocument();
expect(screen.getByText('Share')).toBeInTheDocument();
userEvent.hover(screen.getByText('Share'));
expect(
await screen.findByText('Copy permalink to clipboard'),
).toBeInTheDocument();
expect(await screen.findByText('Embed code')).toBeInTheDocument();
expect(await screen.findByText('Share chart by email')).toBeInTheDocument();
});
test('Should open report submenu if report exists', async () => {
const props = createProps();
props.canAddReports = true;
render(<ExploreAdditionalActionsMenu {...props} />, {
useRedux: true,
initialState: {
reports: {
'1': { name: 'Test report' },
},
},
});
userEvent.click(screen.getByRole('button'));
expect(screen.queryByText('Email reports active')).not.toBeInTheDocument();
expect(screen.queryByText('Edit email report')).not.toBeInTheDocument();
expect(screen.queryByText('Download as image')).not.toBeInTheDocument();
expect(screen.getByText('Manage email report')).toBeInTheDocument();
userEvent.hover(screen.getByText('Manage email report'));
expect(await screen.findByText('Email reports active')).toBeInTheDocument();
expect(await screen.findByText('Edit email report')).toBeInTheDocument();
expect(await screen.findByText('Delete email report')).toBeInTheDocument();
});
test('Should call onOpenPropertiesModal when click on "Edit chart properties"', () => {
const props = createProps();
render(<ExploreAdditionalActionsMenu {...props} />, {
useRedux: true,
});
expect(props.onOpenInEditor).toBeCalledTimes(0);
userEvent.click(screen.getByRole('button'));
userEvent.click(screen.getByRole('menuitem', { name: 'Edit properties' }));
userEvent.click(
screen.getByRole('menuitem', { name: 'Edit chart properties' }),
);
expect(props.onOpenPropertiesModal).toBeCalledTimes(1);
});
@@ -162,18 +243,89 @@ test('Should call onOpenInEditor when click on "Run in SQL Lab"', () => {
expect(props.onOpenInEditor).toBeCalledTimes(1);
});
test('Should call downloadAsImage when click on "Download as image"', () => {
const props = createProps();
const spy = jest.spyOn(downloadAsImage, 'default');
render(<ExploreAdditionalActionsMenu {...props} />, {
useRedux: true,
describe('Download', () => {
let spyDownloadAsImage = sinon.spy();
let spyExportChart = sinon.spy();
beforeEach(() => {
spyDownloadAsImage = sinon.spy(downloadAsImage, 'default');
spyExportChart = sinon.spy(exploreUtils, 'exportChart');
});
afterEach(() => {
spyDownloadAsImage.restore();
spyExportChart.restore();
});
test('Should call downloadAsImage when click on "Download as image"', async () => {
const props = createProps();
const spy = jest.spyOn(downloadAsImage, 'default');
render(<ExploreAdditionalActionsMenu {...props} />, {
useRedux: true,
});
expect(spy).toBeCalledTimes(0);
userEvent.click(screen.getByRole('button'));
expect(spy).toBeCalledTimes(0);
userEvent.hover(screen.getByText('Download'));
const downloadAsImageElement = await screen.findByText('Download as image');
userEvent.click(downloadAsImageElement);
expect(spy).toBeCalledTimes(1);
});
expect(spy).toBeCalledTimes(0);
userEvent.click(screen.getByRole('button'));
expect(spy).toBeCalledTimes(0);
test('Should not export to CSV if canDownloadCSV=false', async () => {
const props = createProps();
render(<ExploreAdditionalActionsMenu {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByRole('button'));
userEvent.hover(screen.getByText('Download'));
const exportCSVElement = await screen.findByText('Export to .CSV');
userEvent.click(exportCSVElement);
expect(spyExportChart.callCount).toBe(0);
spyExportChart.restore();
});
userEvent.click(screen.getByRole('menuitem', { name: 'Download as image' }));
test('Should export to CSV if canDownloadCSV=true', async () => {
const props = createProps();
props.canDownloadCSV = true;
render(<ExploreAdditionalActionsMenu {...props} />, {
useRedux: true,
});
expect(spy).toBeCalledTimes(1);
userEvent.click(screen.getByRole('button'));
userEvent.hover(screen.getByText('Download'));
const exportCSVElement = await screen.findByText('Export to .CSV');
userEvent.click(exportCSVElement);
expect(spyExportChart.callCount).toBe(1);
spyExportChart.restore();
});
test('Should export to JSON', async () => {
const props = createProps();
render(<ExploreAdditionalActionsMenu {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByRole('button'));
userEvent.hover(screen.getByText('Download'));
const exportJsonElement = await screen.findByText('Export to .JSON');
userEvent.click(exportJsonElement);
expect(spyExportChart.callCount).toBe(1);
});
test('Should export to pivoted CSV if canDownloadCSV=true and viz_type=pivot_table_v2', async () => {
const props = createProps();
props.canDownloadCSV = true;
props.latestQueryFormData.viz_type = 'pivot_table_v2';
render(<ExploreAdditionalActionsMenu {...props} />, {
useRedux: true,
});
userEvent.click(screen.getByRole('button'));
userEvent.hover(screen.getByText('Download'));
const exportCSVElement = await screen.findByText('Export to pivoted .CSV');
userEvent.click(exportCSVElement);
expect(spyExportChart.callCount).toBe(1);
});
});

View File

@@ -0,0 +1,92 @@
/**
* 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, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import pick from 'lodash/pick';
import { t } from '@superset-ui/core';
import ReportModal from 'src/components/ReportModal';
import { ExplorePageState } from 'src/explore/reducers/getInitialState';
import DeleteModal from 'src/components/DeleteModal';
import { deleteActiveReport } from 'src/reports/actions/reports';
import { ChartState } from 'src/explore/types';
type ReportMenuItemsProps = {
report: Record<string, any>;
isVisible: boolean;
onHide: () => void;
isDeleting: boolean;
setIsDeleting: (isDeleting: boolean) => void;
};
export const ExploreReport = ({
report,
isVisible,
onHide,
isDeleting,
setIsDeleting,
}: ReportMenuItemsProps) => {
const dispatch = useDispatch();
const chart = useSelector<ExplorePageState, ChartState | undefined>(state => {
if (!state.charts) {
return undefined;
}
const charts = Object.values(state.charts);
if (charts.length > 0) {
return charts[0];
}
return undefined;
});
const { userId, email } = useSelector<
ExplorePageState,
{ userId: number; email: string }
>(state => pick(state.explore.user, ['userId', 'email']));
const handleReportDelete = useCallback(() => {
dispatch(deleteActiveReport(report));
setIsDeleting(false);
}, [dispatch, report, setIsDeleting]);
return (
<>
<ReportModal
show={isVisible}
onHide={onHide}
userId={userId}
userEmail={email}
chart={chart}
creationMethod="charts"
/>
{isDeleting && (
<DeleteModal
description={t(
'This action will permanently delete %s.',
report.name,
)}
onConfirm={() => {
if (report) {
handleReportDelete();
}
}}
onHide={() => setIsDeleting(false)}
open
title={t('Delete Report?')}
/>
)}
</>
);
};

View File

@@ -16,114 +16,430 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { t } from '@superset-ui/core';
import { FileOutlined, FileImageOutlined } from '@ant-design/icons';
import { css, styled, t, useTheme } from '@superset-ui/core';
import { AntdDropdown } from 'src/components';
import { Menu } from 'src/components/Menu';
import downloadAsImage from 'src/utils/downloadAsImage';
import Icons from 'src/components/Icons';
import ModalTrigger from 'src/components/ModalTrigger';
import { sliceUpdated } from 'src/explore/actions/exploreActions';
import Button from 'src/components/Button';
import withToasts from 'src/components/MessageToasts/withToasts';
import Checkbox from 'src/components/Checkbox';
import { exportChart } from 'src/explore/exploreUtils';
import downloadAsImage from 'src/utils/downloadAsImage';
import { noOp } from 'src/utils/common';
import { getChartPermalink } from 'src/utils/urlUtils';
import { toggleActive } from 'src/reports/actions/reports';
import ViewQueryModal from '../controls/ViewQueryModal';
import EmbedCodeContent from '../EmbedCodeContent';
import { ExploreReport } from './ExploreReport';
import copyTextToClipboard from '../../../utils/copy';
const propTypes = {
onOpenPropertiesModal: PropTypes.func,
onOpenInEditor: PropTypes.func,
chartStatus: PropTypes.string,
latestQueryFormData: PropTypes.object.isRequired,
slice: PropTypes.object,
};
const MENU_KEYS = {
EDIT_PROPERTIES: 'edit_properties',
RUN_IN_SQL_LAB: 'run_in_sql_lab',
DOWNLOAD_SUBMENU: 'download_submenu',
EXPORT_TO_CSV: 'export_to_csv',
EXPORT_TO_CSV_PIVOTED: 'export_to_csv_pivoted',
EXPORT_TO_JSON: 'export_to_json',
DOWNLOAD_AS_IMAGE: 'download_as_image',
SHARE_SUBMENU: 'share_submenu',
COPY_PERMALINK: 'copy_permalink',
EMBED_CODE: 'embed_code',
SHARE_BY_EMAIL: 'share_by_email',
REPORT_SUBMENU: 'report_submenu',
SET_UP_REPORT: 'set_up_report',
SET_REPORT_ACTIVE: 'set_report_active',
EDIT_REPORT: 'edit_report',
DELETE_REPORT: 'delete_report',
VIEW_QUERY: 'view_query',
RUN_IN_SQL_LAB: 'run_in_sql_lab',
};
const ExploreAdditionalActionsMenu = props => {
const { datasource } = props.latestQueryFormData;
const sqlSupported = datasource && datasource.split('__')[1] === 'table';
const handleMenuClick = ({ key, domEvent }) => {
const { slice, onOpenInEditor, latestQueryFormData } = props;
switch (key) {
case MENU_KEYS.EDIT_PROPERTIES:
props.onOpenPropertiesModal();
break;
case MENU_KEYS.RUN_IN_SQL_LAB:
onOpenInEditor(latestQueryFormData);
break;
case MENU_KEYS.DOWNLOAD_AS_IMAGE:
downloadAsImage(
'.panel-body > .chart-container',
// eslint-disable-next-line camelcase
slice?.slice_name ?? t('New chart'),
{},
true,
)(domEvent);
break;
default:
break;
}
};
const VIZ_TYPES_PIVOTABLE = ['pivot_table', 'pivot_table_v2'];
const { slice } = props;
return (
<AntdDropdown
trigger="click"
data-test="query-dropdown"
overlay={
<Menu onClick={handleMenuClick} selectable={false}>
{slice && (
<Menu.Item key={MENU_KEYS.EDIT_PROPERTIES}>
{t('Edit properties')}
</Menu.Item>
)}
<Menu.Item key={MENU_KEYS.VIEW_QUERY}>
<ModalTrigger
triggerNode={
<span data-test="view-query-menu-item">{t('View query')}</span>
}
modalTitle={t('View query')}
modalBody={
<ViewQueryModal
latestQueryFormData={props.latestQueryFormData}
/>
}
draggable
resizable
responsive
/>
</Menu.Item>
{sqlSupported && (
<Menu.Item key={MENU_KEYS.RUN_IN_SQL_LAB}>
{t('Run in SQL Lab')}
</Menu.Item>
)}
<Menu.Item key={MENU_KEYS.DOWNLOAD_AS_IMAGE}>
{t('Download as image')}
</Menu.Item>
</Menu>
const MenuItemWithCheckboxContainer = styled.div`
${({ theme }) => css`
display: flex;
align-items: center;
& svg {
width: ${theme.gridUnit * 3}px;
height: ${theme.gridUnit * 3}px;
}
& span[role='checkbox'] {
display: inline-flex;
margin-right: ${theme.gridUnit}px;
}
`}
`;
const MenuTrigger = styled(Button)`
${({ theme }) => css`
width: ${theme.gridUnit * 6}px;
height: ${theme.gridUnit * 6}px;
padding: 0;
border: 1px solid ${theme.colors.primary.dark2};
&.ant-btn > span.anticon {
line-height: 0;
transition: inherit;
}
&:hover:not(:focus) > span.anticon {
color: ${theme.colors.primary.light1};
}
`}
`;
const ExploreAdditionalActionsMenu = ({
latestQueryFormData,
canDownloadCSV,
addDangerToast,
addSuccessToast,
slice,
onOpenInEditor,
onOpenPropertiesModal,
canAddReports,
}) => {
const theme = useTheme();
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [openSubmenus, setOpenSubmenus] = useState([]);
const [showReportModal, setShowReportModal] = useState(false);
const [showDeleteReportModal, setShowDeleteReportModal] = useState(false);
const dispatch = useDispatch();
const report = useSelector(state => {
if (!state.reports) {
return undefined;
}
const reports = Object.values(state?.reports);
if (reports.length > 0) {
return reports[0];
}
return undefined;
});
const isReportActive = report?.active;
const { datasource } = latestQueryFormData;
const sqlSupported = datasource && datasource.split('__')[1] === 'table';
const shareByEmail = useCallback(async () => {
try {
const subject = t('Superset Chart');
const url = await getChartPermalink(latestQueryFormData);
const body = encodeURIComponent(t('%s%s', 'Check out this chart: ', url));
window.location.href = `mailto:?Subject=${subject}%20&Body=${body}`;
} catch (error) {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
}, [addDangerToast, latestQueryFormData]);
const exportCSV = useCallback(
() =>
canDownloadCSV
? exportChart({
formData: latestQueryFormData,
resultType: 'full',
resultFormat: 'csv',
})
: null,
[canDownloadCSV, latestQueryFormData],
);
const exportCSVPivoted = useCallback(
() =>
canDownloadCSV
? exportChart({
formData: latestQueryFormData,
resultType: 'post_processed',
resultFormat: 'csv',
})
: null,
[canDownloadCSV, latestQueryFormData],
);
const exportJson = useCallback(
() =>
exportChart({
formData: latestQueryFormData,
resultType: 'results',
resultFormat: 'json',
}),
[latestQueryFormData],
);
const copyLink = useCallback(async () => {
try {
if (!latestQueryFormData) {
throw new Error();
}
>
<div
role="button"
id="query"
tabIndex={0}
className="btn btn-default btn-sm"
const url = await getChartPermalink(latestQueryFormData);
await copyTextToClipboard(url);
addSuccessToast(t('Copied to clipboard!'));
} catch (error) {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
}, [addDangerToast, addSuccessToast, latestQueryFormData]);
const handleMenuClick = useCallback(
({ key, domEvent }) => {
switch (key) {
case MENU_KEYS.EDIT_PROPERTIES:
onOpenPropertiesModal();
setIsDropdownVisible(false);
break;
case MENU_KEYS.EXPORT_TO_CSV:
exportCSV();
setIsDropdownVisible(false);
setOpenSubmenus([]);
break;
case MENU_KEYS.EXPORT_TO_CSV_PIVOTED:
exportCSVPivoted();
setIsDropdownVisible(false);
setOpenSubmenus([]);
break;
case MENU_KEYS.EXPORT_TO_JSON:
exportJson();
setIsDropdownVisible(false);
setOpenSubmenus([]);
break;
case MENU_KEYS.DOWNLOAD_AS_IMAGE:
downloadAsImage(
'.panel-body > .chart-container',
// eslint-disable-next-line camelcase
slice?.slice_name ?? t('New chart'),
{},
true,
)(domEvent);
setIsDropdownVisible(false);
setOpenSubmenus([]);
break;
case MENU_KEYS.COPY_PERMALINK:
copyLink();
setIsDropdownVisible(false);
setOpenSubmenus([]);
break;
case MENU_KEYS.EMBED_CODE:
setIsDropdownVisible(false);
setOpenSubmenus([]);
break;
case MENU_KEYS.SHARE_BY_EMAIL:
shareByEmail();
setIsDropdownVisible(false);
setOpenSubmenus([]);
break;
case MENU_KEYS.SET_UP_REPORT:
setShowReportModal(true);
setIsDropdownVisible(false);
break;
case MENU_KEYS.SET_REPORT_ACTIVE:
dispatch(toggleActive(report, !isReportActive));
break;
case MENU_KEYS.EDIT_REPORT:
setShowReportModal(true);
setIsDropdownVisible(false);
setOpenSubmenus([]);
break;
case MENU_KEYS.DELETE_REPORT:
setShowDeleteReportModal(true);
setIsDropdownVisible(false);
setOpenSubmenus([]);
break;
case MENU_KEYS.VIEW_QUERY:
setIsDropdownVisible(false);
break;
case MENU_KEYS.RUN_IN_SQL_LAB:
onOpenInEditor(latestQueryFormData);
setIsDropdownVisible(false);
break;
default:
break;
}
},
[
copyLink,
dispatch,
exportCSV,
exportCSVPivoted,
exportJson,
isReportActive,
latestQueryFormData,
onOpenInEditor,
onOpenPropertiesModal,
report,
shareByEmail,
slice?.slice_name,
],
);
return (
<>
<AntdDropdown
trigger="click"
data-test="query-dropdown"
visible={isDropdownVisible}
onVisibleChange={setIsDropdownVisible}
overlay={
<Menu
onClick={handleMenuClick}
selectable={false}
openKeys={openSubmenus}
onOpenChange={setOpenSubmenus}
>
{slice && (
<>
<Menu.Item key={MENU_KEYS.EDIT_PROPERTIES}>
{t('Edit chart properties')}
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.SubMenu
title={t('Download')}
key={MENU_KEYS.DOWNLOAD_SUBMENU}
>
{VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type) ? (
<>
<Menu.Item
key={MENU_KEYS.EXPORT_TO_CSV}
icon={<FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to original .CSV')}
</Menu.Item>
<Menu.Item
key={MENU_KEYS.EXPORT_TO_CSV_PIVOTED}
icon={<FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to pivoted .CSV')}
</Menu.Item>
</>
) : (
<Menu.Item
key={MENU_KEYS.EXPORT_TO_CSV}
icon={<FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to .CSV')}
</Menu.Item>
)}
<Menu.Item key={MENU_KEYS.EXPORT_TO_JSON} icon={<FileOutlined />}>
{t('Export to .JSON')}
</Menu.Item>
<Menu.Item
key={MENU_KEYS.DOWNLOAD_AS_IMAGE}
icon={<FileImageOutlined />}
>
{t('Download as image')}
</Menu.Item>
</Menu.SubMenu>
<Menu.SubMenu title={t('Share')} key={MENU_KEYS.SHARE_SUBMENU}>
<Menu.Item key={MENU_KEYS.COPY_PERMALINK}>
{t('Copy permalink to clipboard')}
</Menu.Item>
<Menu.Item key={MENU_KEYS.SHARE_BY_EMAIL}>
{t('Share chart by email')}
</Menu.Item>
<Menu.Item key={MENU_KEYS.EMBED_CODE}>
<ModalTrigger
triggerNode={
<span data-test="embed-code-button">{t('Embed code')}</span>
}
modalTitle={t('Embed code')}
modalBody={
<EmbedCodeContent
formData={latestQueryFormData}
addDangerToast={addDangerToast}
/>
}
maxWidth={`${theme.gridUnit * 100}px`}
destroyOnClose
responsive
/>
</Menu.Item>
</Menu.SubMenu>
<Menu.Divider />
{canAddReports &&
(report ? (
<Menu.SubMenu
title={t('Manage email report')}
key={MENU_KEYS.REPORT_SUBMENU}
>
<Menu.Item key={MENU_KEYS.SET_REPORT_ACTIVE}>
<MenuItemWithCheckboxContainer>
<Checkbox checked={isReportActive} onChange={noOp} />
{t('Email reports active')}
</MenuItemWithCheckboxContainer>
</Menu.Item>
<Menu.Item key={MENU_KEYS.EDIT_REPORT}>
{t('Edit email report')}
</Menu.Item>
<Menu.Item key={MENU_KEYS.DELETE_REPORT}>
{t('Delete email report')}
</Menu.Item>
</Menu.SubMenu>
) : (
<Menu.Item key={MENU_KEYS.SET_UP_REPORT}>
{t('Set up an email report')}
</Menu.Item>
))}
<Menu.Divider />
<Menu.Item key={MENU_KEYS.VIEW_QUERY}>
<ModalTrigger
triggerNode={
<span data-test="view-query-menu-item">
{t('View query')}
</span>
}
modalTitle={t('View query')}
modalBody={
<ViewQueryModal latestQueryFormData={latestQueryFormData} />
}
draggable
resizable
responsive
/>
</Menu.Item>
{sqlSupported && (
<Menu.Item key={MENU_KEYS.RUN_IN_SQL_LAB}>
{t('Run in SQL Lab')}
</Menu.Item>
)}
</Menu>
}
>
<i role="img" className="fa fa-bars" />
</div>
</AntdDropdown>
<MenuTrigger
buttonStyle="tertiary"
aria-label={t('Menu actions trigger')}
>
<Icons.MoreHoriz
iconColor={theme.colors.primary.dark2}
iconSize={theme.typography.sizes.m}
/>
</MenuTrigger>
</AntdDropdown>
<ExploreReport
report={report}
isVisible={showReportModal}
onHide={() => setShowReportModal(false)}
isDeleting={showDeleteReportModal}
setIsDeleting={setShowDeleteReportModal}
/>
</>
);
};
ExploreAdditionalActionsMenu.propTypes = propTypes;
function mapDispatchToProps(dispatch) {
return bindActionCreators({ sliceUpdated }, dispatch);
}
export default connect(null, mapDispatchToProps)(ExploreAdditionalActionsMenu);
export default withToasts(ExploreAdditionalActionsMenu);

View File

@@ -96,14 +96,11 @@ const createProps = () => ({
test('Cancelling changes to the properties should reset previous properties', () => {
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });
const openModal = screen.getByRole('button', {
name: 'Edit chart properties',
});
const newChartName = 'New chart name';
const prevChartName = props.slice_name;
userEvent.click(openModal);
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.click(screen.getByText('Edit chart properties'));
const nameInput = screen.getByRole('textbox', { name: 'Name' });
@@ -114,7 +111,8 @@ test('Cancelling changes to the properties should reset previous properties', ()
userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
userEvent.click(openModal);
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.click(screen.getByText('Edit chart properties'));
expect(screen.getByDisplayValue(prevChartName)).toBeInTheDocument();
});

View File

@@ -20,22 +20,18 @@ import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import PropTypes from 'prop-types';
import Icons from 'src/components/Icons';
import {
CategoricalColorNamespace,
SupersetClient,
styled,
t,
} from '@superset-ui/core';
import { Tooltip } from 'src/components/Tooltip';
import ReportModal from 'src/components/ReportModal';
import {
fetchUISpecificReport,
toggleActive,
deleteActiveReport,
} from 'src/reports/actions/reports';
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
import HeaderReportActionsDropdown from 'src/components/ReportModal/HeaderReportActionsDropdown';
import { chartPropShape } from 'src/dashboard/util/propShapes';
import EditableTitle from 'src/components/EditableTitle';
import AlteredSliceTag from 'src/components/AlteredSliceTag';
@@ -45,8 +41,9 @@ import CachedLabel from 'src/components/CachedLabel';
import PropertiesModal from 'src/explore/components/PropertiesModal';
import { sliceUpdated } from 'src/explore/actions/exploreActions';
import CertifiedBadge from 'src/components/CertifiedBadge';
import ExploreActionButtons from '../ExploreActionButtons';
import withToasts from 'src/components/MessageToasts/withToasts';
import RowCountLabel from '../RowCountLabel';
import ExploreAdditionalActionsMenu from '../ExploreAdditionalActionsMenu';
const CHART_STATUS_MAP = {
failed: 'danger',
@@ -113,13 +110,9 @@ export class ExploreChartHeader extends React.PureComponent {
super(props);
this.state = {
isPropertiesModalOpen: false,
showingReportModal: false,
};
this.openPropertiesModal = this.openPropertiesModal.bind(this);
this.closePropertiesModal = this.closePropertiesModal.bind(this);
this.showReportModal = this.showReportModal.bind(this);
this.hideReportModal = this.hideReportModal.bind(this);
this.renderReportModal = this.renderReportModal.bind(this);
this.fetchChartDashboardData = this.fetchChartDashboardData.bind(this);
}
@@ -209,38 +202,6 @@ export class ExploreChartHeader extends React.PureComponent {
});
}
showReportModal() {
this.setState({ showingReportModal: true });
}
hideReportModal() {
this.setState({ showingReportModal: false });
}
renderReportModal() {
const attachedReportExists = !!Object.keys(this.props.reports).length;
return attachedReportExists ? (
<HeaderReportActionsDropdown
showReportModal={this.showReportModal}
hideReportModal={this.hideReportModal}
toggleActive={this.props.toggleActive}
deleteActiveReport={this.props.deleteActiveReport}
/>
) : (
<>
<span
role="button"
title={t('Schedule email report')}
tabIndex={0}
className="action-button"
onClick={this.showReportModal}
>
<Icons.Calendar />
</span>
</>
);
}
canAddReports() {
if (!isFeatureEnabled(FeatureFlag.ALERT_REPORTS)) {
return false;
@@ -315,20 +276,6 @@ export class ExploreChartHeader extends React.PureComponent {
slice={this.props.slice}
/>
)}
<Tooltip
id="edit-desc-tooltip"
title={t('Edit chart properties')}
>
<span
aria-label={t('Edit chart properties')}
role="button"
tabIndex={0}
className="edit-desc-icon"
onClick={this.openPropertiesModal}
>
<i className="fa fa-edit" />
</span>
</Tooltip>
{this.props.chart.sliceFormData && (
<AlteredSliceTag
className="altered"
@@ -358,27 +305,13 @@ export class ExploreChartHeader extends React.PureComponent {
isRunning={chartStatus === 'loading'}
status={CHART_STATUS_MAP[chartStatus]}
/>
{this.canAddReports() && this.renderReportModal()}
<ReportModal
show={this.state.showingReportModal}
onHide={this.hideReportModal}
props={{
userId: this.props.user.userId,
userEmail: this.props.user.email,
chart: this.props.chart,
creationMethod: 'charts',
}}
/>
<ExploreActionButtons
actions={{
...this.props.actions,
openPropertiesModal: this.openPropertiesModal,
}}
<ExploreAdditionalActionsMenu
onOpenInEditor={this.props.actions.redirectSQLLab}
onOpenPropertiesModal={this.openPropertiesModal}
slice={this.props.slice}
canDownloadCSV={this.props.can_download}
chartStatus={chartStatus}
latestQueryFormData={latestQueryFormData}
queryResponse={queryResponse}
canAddReports={this.canAddReports()}
/>
</div>
</StyledHeader>
@@ -395,4 +328,7 @@ function mapDispatchToProps(dispatch) {
);
}
export default connect(null, mapDispatchToProps)(ExploreChartHeader);
export default connect(
null,
mapDispatchToProps,
)(withToasts(ExploreChartHeader));