feat(explore): export csv data pivoted for Pivot Table [ID-9] (#17512)

* feat(explore): export csv data pivoted for Pivot Table

* Implement dropdown with download csv options

* Change label to "Original"

* Add tests

* Add form data to query context

* Add form data to query context generator

* Explicitly make form_data optional
This commit is contained in:
Kamil Gabryjelski 2021-12-03 12:42:28 +01:00 committed by GitHub
parent b2ffa268cd
commit 07e8837093
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 291 additions and 28 deletions

View File

@ -73,6 +73,7 @@ export default function buildQueryContext(
...hooks, ...hooks,
}, },
}), }),
form_data: formData,
result_format: formData.result_format || 'json', result_format: formData.result_format || 'json',
result_type: formData.result_type || 'full', result_type: formData.result_type || 'full',
}; };

View File

@ -21,7 +21,12 @@ import { DatasourceType } from './Datasource';
import { BinaryOperator, SetOperator, UnaryOperator } from './Operator'; import { BinaryOperator, SetOperator, UnaryOperator } from './Operator';
import { AppliedTimeExtras, TimeRange, TimeRangeEndpoints } from './Time'; import { AppliedTimeExtras, TimeRange, TimeRangeEndpoints } from './Time';
import { AnnotationLayer } from './AnnotationLayer'; import { AnnotationLayer } from './AnnotationLayer';
import { QueryFields, QueryFormColumn, QueryFormMetric } from './QueryFormData'; import {
QueryFields,
QueryFormColumn,
QueryFormData,
QueryFormMetric,
} from './QueryFormData';
import { Maybe } from '../../types'; import { Maybe } from '../../types';
import { PostProcessingRule } from './PostProcessing'; import { PostProcessingRule } from './PostProcessing';
import { JsonObject } from '../../connection'; import { JsonObject } from '../../connection';
@ -158,6 +163,7 @@ export interface QueryContext {
/** Response format */ /** Response format */
result_format: string; result_format: string;
queries: QueryObject[]; queries: QueryObject[];
form_data?: QueryFormData;
} }
export default {}; export default {};

View File

@ -82,4 +82,36 @@ describe('ExploreActionButtons', () => {
spyExportChart.restore(); 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

@ -16,9 +16,9 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import React, { useState } from 'react'; import React, { ReactElement, useState } from 'react';
import cx from 'classnames'; import cx from 'classnames';
import { t } from '@superset-ui/core'; import { QueryFormData, t } from '@superset-ui/core';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
import copyTextToClipboard from 'src/utils/copy'; import copyTextToClipboard from 'src/utils/copy';
@ -27,13 +27,15 @@ import { useUrlShortener } from 'src/common/hooks/useUrlShortener';
import EmbedCodeButton from './EmbedCodeButton'; import EmbedCodeButton from './EmbedCodeButton';
import { exportChart, getExploreLongUrl } from '../exploreUtils'; import { exportChart, getExploreLongUrl } from '../exploreUtils';
import ExploreAdditionalActionsMenu from './ExploreAdditionalActionsMenu'; import ExploreAdditionalActionsMenu from './ExploreAdditionalActionsMenu';
import { ExportToCSVDropdown } from './ExportToCSVDropdown';
type ActionButtonProps = { type ActionButtonProps = {
icon: React.ReactElement; prefixIcon: React.ReactElement;
text?: string; suffixIcon?: React.ReactElement;
text?: string | ReactElement;
tooltip: string; tooltip: string;
className?: string; className?: string;
onClick: React.MouseEventHandler<HTMLElement>; onClick?: React.MouseEventHandler<HTMLElement>;
onTooltipVisibilityChange?: (visible: boolean) => void; onTooltipVisibilityChange?: (visible: boolean) => void;
'data-test'?: string; 'data-test'?: string;
}; };
@ -42,18 +44,27 @@ type ExploreActionButtonsProps = {
actions: { redirectSQLLab: () => void; openPropertiesModal: () => void }; actions: { redirectSQLLab: () => void; openPropertiesModal: () => void };
canDownloadCSV: boolean; canDownloadCSV: boolean;
chartStatus: string; chartStatus: string;
latestQueryFormData: {}; latestQueryFormData: QueryFormData;
queriesResponse: {}; queriesResponse: {};
slice: { slice_name: string }; slice: { slice_name: string };
addDangerToast: Function; addDangerToast: Function;
}; };
const VIZ_TYPES_PIVOTABLE = ['pivot_table', 'pivot_table_v2'];
const ActionButton = (props: ActionButtonProps) => { const ActionButton = (props: ActionButtonProps) => {
const { icon, text, tooltip, className, onTooltipVisibilityChange, ...rest } = const {
props; prefixIcon,
suffixIcon,
text,
tooltip,
className,
onTooltipVisibilityChange,
...rest
} = props;
return ( return (
<Tooltip <Tooltip
id={`${icon}-tooltip`} id={`${prefixIcon}-tooltip`}
placement="top" placement="top"
title={tooltip} title={tooltip}
trigger={['hover']} trigger={['hover']}
@ -71,8 +82,9 @@ const ActionButton = (props: ActionButtonProps) => {
style={{ height: 30 }} style={{ height: 30 }}
{...rest} {...rest}
> >
{icon} {prefixIcon}
{text && <span style={{ marginLeft: 5 }}>{text}</span>} {text && <span style={{ marginLeft: 5 }}>{text}</span>}
{suffixIcon}
</div> </div>
</Tooltip> </Tooltip>
); );
@ -123,6 +135,14 @@ const ExploreActionButtons = (props: ExploreActionButtonsProps) => {
}) })
: null; : null;
const doExportCSVPivoted = canDownloadCSV
? exportChart.bind(this, {
formData: latestQueryFormData,
resultType: 'post_processed',
resultFormat: 'csv',
})
: null;
const doExportJson = exportChart.bind(this, { const doExportJson = exportChart.bind(this, {
formData: latestQueryFormData, formData: latestQueryFormData,
resultType: 'results', resultType: 'results',
@ -142,7 +162,7 @@ const ExploreActionButtons = (props: ExploreActionButtonsProps) => {
{latestQueryFormData && ( {latestQueryFormData && (
<> <>
<ActionButton <ActionButton
icon={<Icons.Link iconSize="l" />} prefixIcon={<Icons.Link iconSize="l" />}
tooltip={copyTooltip} tooltip={copyTooltip}
onClick={doCopyLink} onClick={doCopyLink}
data-test="short-link-button" data-test="short-link-button"
@ -151,24 +171,47 @@ const ExploreActionButtons = (props: ExploreActionButtonsProps) => {
} }
/> />
<ActionButton <ActionButton
icon={<Icons.Email iconSize="l" />} prefixIcon={<Icons.Email iconSize="l" />}
tooltip={t('Share chart by email')} tooltip={t('Share chart by email')}
onClick={doShareEmail} onClick={doShareEmail}
/> />
<EmbedCodeButton latestQueryFormData={latestQueryFormData} /> <EmbedCodeButton latestQueryFormData={latestQueryFormData} />
<ActionButton <ActionButton
icon={<Icons.FileTextOutlined iconSize="m" />} prefixIcon={<Icons.FileTextOutlined iconSize="m" />}
text=".JSON" text=".JSON"
tooltip={t('Export to .JSON format')} tooltip={t('Export to .JSON format')}
onClick={doExportJson} onClick={doExportJson}
/> />
<ActionButton {VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type) ? (
icon={<Icons.FileExcelOutlined iconSize="m" />} <ExportToCSVDropdown
text=".CSV" exportCSVOriginal={doExportCSV}
tooltip={t('Export to .CSV format')} exportCSVPivoted={doExportCSVPivoted}
onClick={doExportCSV} >
className={exportToCSVClasses} <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 <ExploreAdditionalActionsMenu

View File

@ -0,0 +1,75 @@
/**
* 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 userEvent from '@testing-library/user-event';
import { render, screen } from 'spec/helpers/testing-library';
import { ExportToCSVDropdown } from './index';
const exportCSVOriginal = jest.fn();
const exportCSVPivoted = jest.fn();
test('Dropdown button with menu renders', () => {
render(
<ExportToCSVDropdown
exportCSVOriginal={exportCSVOriginal}
exportCSVPivoted={exportCSVPivoted}
>
<div>.CSV</div>
</ExportToCSVDropdown>,
);
expect(screen.getByText('.CSV')).toBeVisible();
userEvent.click(screen.getByText('.CSV'));
expect(screen.getByRole('menu')).toBeInTheDocument();
expect(screen.getByText('Original')).toBeInTheDocument();
expect(screen.getByText('Pivoted')).toBeInTheDocument();
});
test('Call export csv original on click', () => {
render(
<ExportToCSVDropdown
exportCSVOriginal={exportCSVOriginal}
exportCSVPivoted={exportCSVPivoted}
>
<div>.CSV</div>
</ExportToCSVDropdown>,
);
userEvent.click(screen.getByText('.CSV'));
userEvent.click(screen.getByText('Original'));
expect(exportCSVOriginal).toHaveBeenCalled();
});
test('Call export csv pivoted on click', () => {
render(
<ExportToCSVDropdown
exportCSVOriginal={exportCSVOriginal}
exportCSVPivoted={exportCSVPivoted}
>
<div>.CSV</div>
</ExportToCSVDropdown>,
);
userEvent.click(screen.getByText('.CSV'));
userEvent.click(screen.getByText('Pivoted'));
expect(exportCSVPivoted).toHaveBeenCalled();
});

View File

@ -0,0 +1,90 @@
/**
* 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, { ReactChild, useCallback } from 'react';
import { t, styled } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { Dropdown, Menu } from 'src/common/components';
enum MENU_KEYS {
EXPORT_ORIGINAL = 'export_original',
EXPORT_PIVOTED = 'export_pivoted',
}
interface ExportToCSVButtonProps {
exportCSVOriginal: () => void;
exportCSVPivoted: () => void;
children: ReactChild;
}
const MenuItemContent = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
span[role='img'] {
font-size: ${({ theme }) => theme.typography.sizes.l}px;
margin-left: ${({ theme }) => theme.gridUnit * 4}px;
}
`;
export const ExportToCSVDropdown = ({
exportCSVOriginal,
exportCSVPivoted,
children,
}: ExportToCSVButtonProps) => {
const handleMenuClick = useCallback(
({ key }: { key: React.Key }) => {
switch (key) {
case MENU_KEYS.EXPORT_ORIGINAL:
exportCSVOriginal();
break;
case MENU_KEYS.EXPORT_PIVOTED:
exportCSVPivoted();
break;
default:
break;
}
},
[exportCSVPivoted, exportCSVOriginal],
);
return (
<Dropdown
trigger={['click']}
overlay={
<Menu onClick={handleMenuClick} selectable={false}>
<Menu.Item key={MENU_KEYS.EXPORT_ORIGINAL}>
<MenuItemContent>
{t('Original')}
<Icons.Download />
</MenuItemContent>
</Menu.Item>
<Menu.Item key={MENU_KEYS.EXPORT_PIVOTED}>
<MenuItemContent>
{t('Pivoted')}
<Icons.Download />
</MenuItemContent>
</Menu.Item>
</Menu>
}
>
{children}
</Dropdown>
);
};

View File

@ -242,7 +242,8 @@ class ChartDataRestApi(ChartRestApi):
): ):
return self._run_async(json_body, command) return self._run_async(json_body, command)
return self._get_data_response(command) form_data = json_body.get("form_data")
return self._get_data_response(command, form_data=form_data)
@expose("/data/<cache_key>", methods=["GET"]) @expose("/data/<cache_key>", methods=["GET"])
@protect() @protect()

View File

@ -1160,6 +1160,8 @@ class ChartDataQueryContextSchema(Schema):
result_type = EnumField(ChartDataResultType, by_value=True) result_type = EnumField(ChartDataResultType, by_value=True)
result_format = EnumField(ChartDataResultFormat, by_value=True) result_format = EnumField(ChartDataResultFormat, by_value=True)
form_data = fields.Raw(allow_none=True, required=False)
# pylint: disable=unused-argument # pylint: disable=unused-argument
@post_load @post_load
def make_query_context(self, data: Dict[str, Any], **kwargs: Any) -> QueryContext: def make_query_context(self, data: Dict[str, Any], **kwargs: Any) -> QueryContext:

View File

@ -47,6 +47,7 @@ class QueryContext:
datasource: BaseDatasource datasource: BaseDatasource
queries: List[QueryObject] queries: List[QueryObject]
form_data: Optional[Dict[str, Any]]
result_type: ChartDataResultType result_type: ChartDataResultType
result_format: ChartDataResultFormat result_format: ChartDataResultFormat
force: bool force: bool
@ -63,6 +64,7 @@ class QueryContext:
*, *,
datasource: BaseDatasource, datasource: BaseDatasource,
queries: List[QueryObject], queries: List[QueryObject],
form_data: Optional[Dict[str, Any]],
result_type: ChartDataResultType, result_type: ChartDataResultType,
result_format: ChartDataResultFormat, result_format: ChartDataResultFormat,
force: bool = False, force: bool = False,
@ -73,6 +75,7 @@ class QueryContext:
self.result_type = result_type self.result_type = result_type
self.result_format = result_format self.result_format = result_format
self.queries = queries self.queries = queries
self.form_data = form_data
self.force = force self.force = force
self.custom_cache_timeout = custom_cache_timeout self.custom_cache_timeout = custom_cache_timeout
self.cache_values = cache_values self.cache_values = cache_values

View File

@ -46,6 +46,7 @@ class QueryContextFactory: # pylint: disable=too-few-public-methods
*, *,
datasource: DatasourceDict, datasource: DatasourceDict,
queries: List[Dict[str, Any]], queries: List[Dict[str, Any]],
form_data: Optional[Dict[str, Any]] = None,
result_type: Optional[ChartDataResultType] = None, result_type: Optional[ChartDataResultType] = None,
result_format: Optional[ChartDataResultFormat] = None, result_format: Optional[ChartDataResultFormat] = None,
force: bool = False, force: bool = False,
@ -69,6 +70,7 @@ class QueryContextFactory: # pylint: disable=too-few-public-methods
return QueryContext( return QueryContext(
datasource=datasource_model_instance, datasource=datasource_model_instance,
queries=queries_, queries=queries_,
form_data=form_data,
result_type=result_type, result_type=result_type,
result_format=result_format, result_format=result_format,
force=force, force=force,

View File

@ -16,7 +16,7 @@
# under the License. # under the License.
import copy import copy
import dataclasses import dataclasses
from typing import Any, Dict, List from typing import Any, Dict, List, Optional
from superset.common.chart_data import ChartDataResultType from superset.common.chart_data import ChartDataResultType
from superset.utils.core import AnnotationType, DTTM_ALIAS, TimeRangeEndpoint from superset.utils.core import AnnotationType, DTTM_ALIAS, TimeRangeEndpoint
@ -242,7 +242,9 @@ class QueryContextGenerator:
add_time_offsets: bool = False, add_time_offsets: bool = False,
table_id=1, table_id=1,
table_type="table", table_type="table",
form_data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
form_data = form_data or {}
table_name = query_name.split(":")[0] table_name = query_name.split(":")[0]
table = self.get_table(table_name, table_id, table_type) table = self.get_table(table_name, table_id, table_type)
return { return {
@ -253,7 +255,8 @@ class QueryContextGenerator:
) )
], ],
"result_type": ChartDataResultType.FULL, "result_type": ChartDataResultType.FULL,
"form_data": form_data,
} }
def get_table(self, name, id, type): def get_table(self, name, id_, type_):
return Table(id, type, name) return Table(id_, type_, name)

View File

@ -14,14 +14,14 @@
# KIND, either express or implied. See the License for the # KIND, either express or implied. See the License for the
# specific language governing permissions and limitations # specific language governing permissions and limitations
# under the License. # under the License.
from typing import Any, Dict from typing import Any, Dict, Optional
from tests.common.query_context_generator import QueryContextGenerator from tests.common.query_context_generator import QueryContextGenerator
from tests.integration_tests.base_tests import SupersetTestCase from tests.integration_tests.base_tests import SupersetTestCase
class QueryContextGeneratorInteg(QueryContextGenerator): class QueryContextGeneratorInteg(QueryContextGenerator):
def get_table(self, name, id, type): def get_table(self, name, id_, type_):
return SupersetTestCase.get_table(name=name) return SupersetTestCase.get_table(name=name)
@ -29,6 +29,7 @@ def get_query_context(
query_name: str, query_name: str,
add_postprocessing_operations: bool = False, add_postprocessing_operations: bool = False,
add_time_offsets: bool = False, add_time_offsets: bool = False,
form_data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Create a request payload for retrieving a QueryContext object via the Create a request payload for retrieving a QueryContext object via the
@ -40,8 +41,12 @@ def get_query_context(
:param datasource_type: type of datasource to query. :param datasource_type: type of datasource to query.
:param add_postprocessing_operations: Add post-processing operations to QueryObject :param add_postprocessing_operations: Add post-processing operations to QueryObject
:param add_time_offsets: Add time offsets to QueryObject(advanced analytics) :param add_time_offsets: Add time offsets to QueryObject(advanced analytics)
:param form_data: chart metadata
:return: Request payload :return: Request payload
""" """
return QueryContextGeneratorInteg().generate( return QueryContextGeneratorInteg().generate(
query_name, add_postprocessing_operations, add_time_offsets query_name=query_name,
add_postprocessing_operations=add_postprocessing_operations,
add_time_offsets=add_time_offsets,
form_data=form_data,
) )