diff --git a/superset-frontend/packages/superset-ui-core/src/query/buildQueryContext.ts b/superset-frontend/packages/superset-ui-core/src/query/buildQueryContext.ts
index fce53b4608..070636f156 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/buildQueryContext.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/buildQueryContext.ts
@@ -73,6 +73,7 @@ export default function buildQueryContext(
...hooks,
},
}),
+ form_data: formData,
result_format: formData.result_format || 'json',
result_type: formData.result_type || 'full',
};
diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
index e51b568e03..41708d9dc5 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
@@ -21,7 +21,12 @@ import { DatasourceType } from './Datasource';
import { BinaryOperator, SetOperator, UnaryOperator } from './Operator';
import { AppliedTimeExtras, TimeRange, TimeRangeEndpoints } from './Time';
import { AnnotationLayer } from './AnnotationLayer';
-import { QueryFields, QueryFormColumn, QueryFormMetric } from './QueryFormData';
+import {
+ QueryFields,
+ QueryFormColumn,
+ QueryFormData,
+ QueryFormMetric,
+} from './QueryFormData';
import { Maybe } from '../../types';
import { PostProcessingRule } from './PostProcessing';
import { JsonObject } from '../../connection';
@@ -158,6 +163,7 @@ export interface QueryContext {
/** Response format */
result_format: string;
queries: QueryObject[];
+ form_data?: QueryFormData;
}
export default {};
diff --git a/superset-frontend/spec/javascripts/explore/components/ExploreActionButtons_spec.jsx b/superset-frontend/spec/javascripts/explore/components/ExploreActionButtons_spec.jsx
index 6598f3f0d4..8e7db84901 100644
--- a/superset-frontend/spec/javascripts/explore/components/ExploreActionButtons_spec.jsx
+++ b/superset-frontend/spec/javascripts/explore/components/ExploreActionButtons_spec.jsx
@@ -82,4 +82,36 @@ describe('ExploreActionButtons', () => {
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(
+
+
+ ,
+ {
+ 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();
+ });
+ });
});
diff --git a/superset-frontend/src/explore/components/ExploreActionButtons.tsx b/superset-frontend/src/explore/components/ExploreActionButtons.tsx
index c16d124245..b6cfc70578 100644
--- a/superset-frontend/src/explore/components/ExploreActionButtons.tsx
+++ b/superset-frontend/src/explore/components/ExploreActionButtons.tsx
@@ -16,9 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
-import React, { useState } from 'react';
+import React, { ReactElement, useState } from 'react';
import cx from 'classnames';
-import { t } from '@superset-ui/core';
+import { QueryFormData, t } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { Tooltip } from 'src/components/Tooltip';
import copyTextToClipboard from 'src/utils/copy';
@@ -27,13 +27,15 @@ import { useUrlShortener } from 'src/common/hooks/useUrlShortener';
import EmbedCodeButton from './EmbedCodeButton';
import { exportChart, getExploreLongUrl } from '../exploreUtils';
import ExploreAdditionalActionsMenu from './ExploreAdditionalActionsMenu';
+import { ExportToCSVDropdown } from './ExportToCSVDropdown';
type ActionButtonProps = {
- icon: React.ReactElement;
- text?: string;
+ prefixIcon: React.ReactElement;
+ suffixIcon?: React.ReactElement;
+ text?: string | ReactElement;
tooltip: string;
className?: string;
- onClick: React.MouseEventHandler;
+ onClick?: React.MouseEventHandler;
onTooltipVisibilityChange?: (visible: boolean) => void;
'data-test'?: string;
};
@@ -42,18 +44,27 @@ type ExploreActionButtonsProps = {
actions: { redirectSQLLab: () => void; openPropertiesModal: () => void };
canDownloadCSV: boolean;
chartStatus: string;
- latestQueryFormData: {};
+ latestQueryFormData: QueryFormData;
queriesResponse: {};
slice: { slice_name: string };
addDangerToast: Function;
};
+const VIZ_TYPES_PIVOTABLE = ['pivot_table', 'pivot_table_v2'];
+
const ActionButton = (props: ActionButtonProps) => {
- const { icon, text, tooltip, className, onTooltipVisibilityChange, ...rest } =
- props;
+ const {
+ prefixIcon,
+ suffixIcon,
+ text,
+ tooltip,
+ className,
+ onTooltipVisibilityChange,
+ ...rest
+ } = props;
return (
{
style={{ height: 30 }}
{...rest}
>
- {icon}
+ {prefixIcon}
{text && {text}}
+ {suffixIcon}
);
@@ -123,6 +135,14 @@ const ExploreActionButtons = (props: ExploreActionButtonsProps) => {
})
: null;
+ const doExportCSVPivoted = canDownloadCSV
+ ? exportChart.bind(this, {
+ formData: latestQueryFormData,
+ resultType: 'post_processed',
+ resultFormat: 'csv',
+ })
+ : null;
+
const doExportJson = exportChart.bind(this, {
formData: latestQueryFormData,
resultType: 'results',
@@ -142,7 +162,7 @@ const ExploreActionButtons = (props: ExploreActionButtonsProps) => {
{latestQueryFormData && (
<>
}
+ prefixIcon={}
tooltip={copyTooltip}
onClick={doCopyLink}
data-test="short-link-button"
@@ -151,24 +171,47 @@ const ExploreActionButtons = (props: ExploreActionButtonsProps) => {
}
/>
}
+ prefixIcon={}
tooltip={t('Share chart by email')}
onClick={doShareEmail}
/>
}
+ prefixIcon={}
text=".JSON"
tooltip={t('Export to .JSON format')}
onClick={doExportJson}
/>
- }
- text=".CSV"
- tooltip={t('Export to .CSV format')}
- onClick={doExportCSV}
- className={exportToCSVClasses}
- />
+ {VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type) ? (
+
+ }
+ suffixIcon={
+ `
+ margin-left: ${theme.gridUnit}px;
+ margin-right: ${-theme.gridUnit}px;
+ `}
+ />
+ }
+ text=".CSV"
+ tooltip={t('Export to .CSV format')}
+ className={exportToCSVClasses}
+ />
+
+ ) : (
+ }
+ text=".CSV"
+ tooltip={t('Export to .CSV format')}
+ onClick={doExportCSV}
+ className={exportToCSVClasses}
+ />
+ )}
>
)}
{
+ render(
+
+ .CSV
+ ,
+ );
+
+ 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(
+
+ .CSV
+ ,
+ );
+
+ userEvent.click(screen.getByText('.CSV'));
+ userEvent.click(screen.getByText('Original'));
+
+ expect(exportCSVOriginal).toHaveBeenCalled();
+});
+
+test('Call export csv pivoted on click', () => {
+ render(
+
+ .CSV
+ ,
+ );
+
+ userEvent.click(screen.getByText('.CSV'));
+ userEvent.click(screen.getByText('Pivoted'));
+
+ expect(exportCSVPivoted).toHaveBeenCalled();
+});
diff --git a/superset-frontend/src/explore/components/ExportToCSVDropdown/index.tsx b/superset-frontend/src/explore/components/ExportToCSVDropdown/index.tsx
new file mode 100644
index 0000000000..ec5106711a
--- /dev/null
+++ b/superset-frontend/src/explore/components/ExportToCSVDropdown/index.tsx
@@ -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 (
+
+
+
+ {t('Original')}
+
+
+
+
+
+ {t('Pivoted')}
+
+
+
+
+ }
+ >
+ {children}
+
+ );
+};
diff --git a/superset/charts/data/api.py b/superset/charts/data/api.py
index 6d964091ad..6983152e24 100644
--- a/superset/charts/data/api.py
+++ b/superset/charts/data/api.py
@@ -242,7 +242,8 @@ class ChartDataRestApi(ChartRestApi):
):
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/", methods=["GET"])
@protect()
diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py
index 68afafee7f..8293065d9e 100644
--- a/superset/charts/schemas.py
+++ b/superset/charts/schemas.py
@@ -1160,6 +1160,8 @@ class ChartDataQueryContextSchema(Schema):
result_type = EnumField(ChartDataResultType, by_value=True)
result_format = EnumField(ChartDataResultFormat, by_value=True)
+ form_data = fields.Raw(allow_none=True, required=False)
+
# pylint: disable=unused-argument
@post_load
def make_query_context(self, data: Dict[str, Any], **kwargs: Any) -> QueryContext:
diff --git a/superset/common/query_context.py b/superset/common/query_context.py
index 5ddd174ca2..bc906a92c7 100644
--- a/superset/common/query_context.py
+++ b/superset/common/query_context.py
@@ -47,6 +47,7 @@ class QueryContext:
datasource: BaseDatasource
queries: List[QueryObject]
+ form_data: Optional[Dict[str, Any]]
result_type: ChartDataResultType
result_format: ChartDataResultFormat
force: bool
@@ -63,6 +64,7 @@ class QueryContext:
*,
datasource: BaseDatasource,
queries: List[QueryObject],
+ form_data: Optional[Dict[str, Any]],
result_type: ChartDataResultType,
result_format: ChartDataResultFormat,
force: bool = False,
@@ -73,6 +75,7 @@ class QueryContext:
self.result_type = result_type
self.result_format = result_format
self.queries = queries
+ self.form_data = form_data
self.force = force
self.custom_cache_timeout = custom_cache_timeout
self.cache_values = cache_values
diff --git a/superset/common/query_context_factory.py b/superset/common/query_context_factory.py
index 61cf92835b..50eb6b02f3 100644
--- a/superset/common/query_context_factory.py
+++ b/superset/common/query_context_factory.py
@@ -46,6 +46,7 @@ class QueryContextFactory: # pylint: disable=too-few-public-methods
*,
datasource: DatasourceDict,
queries: List[Dict[str, Any]],
+ form_data: Optional[Dict[str, Any]] = None,
result_type: Optional[ChartDataResultType] = None,
result_format: Optional[ChartDataResultFormat] = None,
force: bool = False,
@@ -69,6 +70,7 @@ class QueryContextFactory: # pylint: disable=too-few-public-methods
return QueryContext(
datasource=datasource_model_instance,
queries=queries_,
+ form_data=form_data,
result_type=result_type,
result_format=result_format,
force=force,
diff --git a/tests/common/query_context_generator.py b/tests/common/query_context_generator.py
index 69bafc175d..fe898c89f0 100644
--- a/tests/common/query_context_generator.py
+++ b/tests/common/query_context_generator.py
@@ -16,7 +16,7 @@
# under the License.
import copy
import dataclasses
-from typing import Any, Dict, List
+from typing import Any, Dict, List, Optional
from superset.common.chart_data import ChartDataResultType
from superset.utils.core import AnnotationType, DTTM_ALIAS, TimeRangeEndpoint
@@ -242,7 +242,9 @@ class QueryContextGenerator:
add_time_offsets: bool = False,
table_id=1,
table_type="table",
+ form_data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
+ form_data = form_data or {}
table_name = query_name.split(":")[0]
table = self.get_table(table_name, table_id, table_type)
return {
@@ -253,7 +255,8 @@ class QueryContextGenerator:
)
],
"result_type": ChartDataResultType.FULL,
+ "form_data": form_data,
}
- def get_table(self, name, id, type):
- return Table(id, type, name)
+ def get_table(self, name, id_, type_):
+ return Table(id_, type_, name)
diff --git a/tests/integration_tests/fixtures/query_context.py b/tests/integration_tests/fixtures/query_context.py
index 40892e7573..e8a3118bf5 100644
--- a/tests/integration_tests/fixtures/query_context.py
+++ b/tests/integration_tests/fixtures/query_context.py
@@ -14,14 +14,14 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from typing import Any, Dict
+from typing import Any, Dict, Optional
from tests.common.query_context_generator import QueryContextGenerator
from tests.integration_tests.base_tests import SupersetTestCase
class QueryContextGeneratorInteg(QueryContextGenerator):
- def get_table(self, name, id, type):
+ def get_table(self, name, id_, type_):
return SupersetTestCase.get_table(name=name)
@@ -29,6 +29,7 @@ def get_query_context(
query_name: str,
add_postprocessing_operations: bool = False,
add_time_offsets: bool = False,
+ form_data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
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 add_postprocessing_operations: Add post-processing operations to QueryObject
:param add_time_offsets: Add time offsets to QueryObject(advanced analytics)
+ :param form_data: chart metadata
:return: Request payload
"""
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,
)