diff --git a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx index d5f30a575a74..9c04fee8e718 100644 --- a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx +++ b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx @@ -446,4 +446,82 @@ describe('ResultSet', () => { }), ).toBe(null); }); + + test('should allow download as CSV when user has permission to export data', async () => { + const { queryByTestId } = setup( + mockedProps, + mockStore({ + ...initialState, + user: { + ...user, + roles: { + sql_lab: [['can_export_csv', 'SQLLab']], + }, + }, + sqlLab: { + ...initialState.sqlLab, + queries: { + [queries[0].id]: queries[0], + }, + }, + }), + ); + expect(queryByTestId('export-csv-button')).toBeInTheDocument(); + }); + + test('should not allow download as CSV when user does not have permission to export data', async () => { + const { queryByTestId } = setup( + mockedProps, + mockStore({ + ...initialState, + user, + sqlLab: { + ...initialState.sqlLab, + queries: { + [queries[0].id]: queries[0], + }, + }, + }), + ); + expect(queryByTestId('export-csv-button')).not.toBeInTheDocument(); + }); + + test('should allow copy to clipboard when user has permission to export data', async () => { + const { queryByTestId } = setup( + mockedProps, + mockStore({ + ...initialState, + user: { + ...user, + roles: { + sql_lab: [['can_export_csv', 'SQLLab']], + }, + }, + sqlLab: { + ...initialState.sqlLab, + queries: { + [queries[0].id]: queries[0], + }, + }, + }), + ); + expect(queryByTestId('copy-to-clipboard-button')).toBeInTheDocument(); + }); + + test('should not allow copy to clipboard when user does not have permission to export data', async () => { + const { queryByTestId } = setup( + mockedProps, + mockStore({ + ...initialState, + user, + sqlLab: { + ...initialState.sqlLab, + queries: { + [queries[0].id]: queries[0], + }, + }, + }), + ); + expect(queryByTestId('copy-to-clipboard-button')).not.toBeInTheDocument(); + }); }); diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx index ac41d19b8243..6e62c17da5ad 100644 --- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx +++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx @@ -78,6 +78,7 @@ import { LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV, } from 'src/logger/LogUtils'; import Icons from 'src/components/Icons'; +import { findPermission } from 'src/utils/findPermission'; import ExploreCtasResultsButton from '../ExploreCtasResultsButton'; import ExploreResultsButton from '../ExploreResultsButton'; import HighlightedSql from '../HighlightedSql'; @@ -309,6 +310,12 @@ const ResultSet = ({ schema: query?.schema, }; + const canExportData = findPermission( + 'can_export_csv', + 'SQLLab', + user?.roles, + ); + return ( )} - {csv && ( + {csv && canExportData && ( )} - - {t('Copy to Clipboard')} - - } - hideTooltip - onCopyEnd={() => - logAction(LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD, {}) - } - /> + {canExportData && ( + + {t('Copy to Clipboard')} + + } + hideTooltip + onCopyEnd={() => + logAction(LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD, {}) + } + /> + )} {search && ( findPermission('can_samples', 'Datasource', state.user?.roles), ); + const canDownload = useSelector((state: RootState) => + findPermission('can_csv', 'Superset', state.user?.roles), + ); const canDrill = useSelector((state: RootState) => findPermission('can_drill', 'Dashboard', state.user?.roles), ); @@ -256,6 +259,7 @@ const ChartContextMenu = ( formData={formData} contextMenuY={clientY} submenuIndex={submenuIndex} + canDownload={canDownload} open={openKeys.includes('drill-by-submenu')} key="drill-by-submenu" {...(additionalConfig?.drillBy || {})} diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx index 8b65d214094b..2e60c08586fa 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx @@ -74,6 +74,7 @@ const renderMenu = ({ diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx index 6df180cb78ca..f694db3d272e 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx @@ -74,6 +74,7 @@ export interface DrillByMenuItemsProps { onClick?: (event: MouseEvent) => void; openNewModal?: boolean; excludedColumns?: Column[]; + canDownload: boolean; open: boolean; } @@ -105,6 +106,7 @@ export const DrillByMenuItems = ({ onClick = () => {}, excludedColumns, openNewModal = true, + canDownload, open, ...rest }: DrillByMenuItemsProps) => { @@ -344,6 +346,7 @@ export const DrillByMenuItems = ({ formData={formData} onHideModal={closeModal} dataset={{ ...dataset!, verbose_map: verboseMap }} + canDownload={canDownload} /> )} diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx index d546c071cbcf..2299f091b6b1 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx @@ -86,6 +86,7 @@ const renderModal = async ( onHideModal={() => setShowModal(false)} dataset={dataset} drillByConfig={{ groupbyFieldName: 'groupby', filters: [] }} + canDownload {...modalProps} /> )} diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx index 0cf6b1f016b4..2a9262e19e98 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx @@ -151,6 +151,7 @@ export interface DrillByModalProps { drillByConfig: Required['drillBy']; formData: BaseFormData & { [key: string]: any }; onHideModal: () => void; + canDownload: boolean; } type DrillByConfigs = (ContextMenuFilters['drillBy'] & { column?: Column })[]; @@ -161,6 +162,7 @@ export default function DrillByModal({ drillByConfig, formData, onHideModal, + canDownload, }: DrillByModalProps) { const dispatch = useDispatch(); const theme = useTheme(); @@ -200,6 +202,7 @@ export default function DrillByModal({ const resultsTable = useResultsTableView( chartDataResult, formData.datasource, + canDownload, ); const [currentFormData, setCurrentFormData] = useState(formData); diff --git a/superset-frontend/src/components/Chart/DrillBy/useResultsTableView.test.ts b/superset-frontend/src/components/Chart/DrillBy/useResultsTableView.test.ts index 36cc51a68431..7e64d3a42f85 100644 --- a/superset-frontend/src/components/Chart/DrillBy/useResultsTableView.test.ts +++ b/superset-frontend/src/components/Chart/DrillBy/useResultsTableView.test.ts @@ -65,7 +65,7 @@ const MOCK_CHART_DATA_RESULT = [ test('Displays results table for 1 query', () => { const { result } = renderHook(() => - useResultsTableView(MOCK_CHART_DATA_RESULT.slice(0, 1), '1__table'), + useResultsTableView(MOCK_CHART_DATA_RESULT.slice(0, 1), '1__table', true), ); render(result.current, { useRedux: true }); expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); @@ -76,7 +76,7 @@ test('Displays results table for 1 query', () => { test('Displays results for 2 queries', async () => { const { result } = renderHook(() => - useResultsTableView(MOCK_CHART_DATA_RESULT, '1__table'), + useResultsTableView(MOCK_CHART_DATA_RESULT, '1__table', true), ); render(result.current, { useRedux: true }); const getActiveTabElement = () => diff --git a/superset-frontend/src/components/Chart/DrillBy/useResultsTableView.tsx b/superset-frontend/src/components/Chart/DrillBy/useResultsTableView.tsx index 8b7fd684b08d..4d85f949b294 100644 --- a/superset-frontend/src/components/Chart/DrillBy/useResultsTableView.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/useResultsTableView.tsx @@ -33,6 +33,7 @@ const PaginationContainer = styled.div` export const useResultsTableView = ( chartDataResult: QueryData[] | undefined, datasourceId: string, + canDownload: boolean, ) => { if (!isDefined(chartDataResult)) { return
; @@ -48,6 +49,7 @@ export const useResultsTableView = ( dataSize={DATA_SIZE} datasourceId={datasourceId} isVisible + canDownload={canDownload} /> ); @@ -65,6 +67,7 @@ export const useResultsTableView = ( dataSize={DATA_SIZE} datasourceId={datasourceId} isVisible + canDownload={canDownload} /> diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 756f2ed9342d..632428f32f8a 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -809,6 +809,7 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => { dataSize={20} isRequest isVisible + canDownload={!!props.supersetCanCSV} /> } /> diff --git a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx index 8601c41a6617..640834ba946a 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx @@ -88,6 +88,7 @@ export const DataTablesPane = ({ ownState, errorMessage, actions, + canDownload, }: DataTablesPaneProps) => { const theme = useTheme(); const [activeTabKey, setActiveTabKey] = useState(ResultTypes.Results); @@ -198,6 +199,7 @@ export const DataTablesPane = ({ isRequest: isRequest.results, actions, isVisible: ResultTypes.Results === activeTabKey, + canDownload, }).map((pane, idx) => { if (idx === 0) { return ( @@ -235,6 +237,7 @@ export const DataTablesPane = ({ isRequest={isRequest.samples} actions={actions} isVisible={ResultTypes.Samples === activeTabKey} + canDownload={canDownload} /> diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx index ff134da1f71d..30dfb87c74a8 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx @@ -49,6 +49,7 @@ export const TableControls = ({ columnTypes, rowcount, isLoading, + canDownload, }: TableControlsProps) => { const originalTimeColumns = getTimeColumns(datasourceId); const formattedTimeColumns = zip( @@ -76,7 +77,9 @@ export const TableControls = ({ `} > - + {canDownload && ( + + )}
); diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/ResultsPaneOnDashboard.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/ResultsPaneOnDashboard.tsx index 410d55cbfd35..b6691f87afb2 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/components/ResultsPaneOnDashboard.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/components/ResultsPaneOnDashboard.tsx @@ -53,6 +53,7 @@ export const ResultsPaneOnDashboard = ({ actions, isVisible, dataSize = 50, + canDownload, }: ResultsPaneProps) => { const resultsPanes = useResultsPane({ errorMessage, @@ -63,6 +64,7 @@ export const ResultsPaneOnDashboard = ({ actions, dataSize, isVisible, + canDownload, }); if (resultsPanes.length === 1) { diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx index dd9f430a337f..ca0a1d6af49b 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx @@ -42,6 +42,7 @@ export const SamplesPane = ({ actions, dataSize = 50, isVisible, + canDownload, }: SamplesPaneProps) => { const [filterText, setFilterText] = useState(''); const [data, setData] = useState[][]>([]); @@ -114,6 +115,7 @@ export const SamplesPane = ({ datasourceId={datasourceId} onInputChange={input => setFilterText(input)} isLoading={isLoading} + canDownload={canDownload} /> {responseError} @@ -135,6 +137,7 @@ export const SamplesPane = ({ datasourceId={datasourceId} onInputChange={input => setFilterText(input)} isLoading={isLoading} + canDownload={canDownload} /> { const [filterText, setFilterText] = useState(''); @@ -60,6 +61,7 @@ export const SingleQueryResultPane = ({ datasourceId={datasourceId} onInputChange={input => setFilterText(input)} isLoading={false} + canDownload={canDownload} /> { const metadata = getChartMetadataRegistry().get( queryFormData?.viz_type || queryFormData?.vizType, @@ -124,6 +125,7 @@ export const useResultsPane = ({ datasourceId={queryFormData.datasource} onInputChange={() => {}} isLoading={false} + canDownload={canDownload} /> {responseError} @@ -149,6 +151,7 @@ export const useResultsPane = ({ datasourceId={queryFormData.datasource} key={idx} isVisible={isVisible} + canDownload={canDownload} /> )); }; diff --git a/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx index fb5a20a0d60d..4dcb5c700015 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx @@ -114,6 +114,34 @@ describe('DataTablesPane', () => { fetchMock.restore(); }); + test('Should not allow copy data table content when canDownload=false', async () => { + fetchMock.post( + 'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D', + { + result: [ + { + data: [{ __timestamp: 1230768000000, genre: 'Action' }], + colnames: ['__timestamp', 'genre'], + coltypes: [2, 1], + rowcount: 1, + sql_rowcount: 1, + }, + ], + }, + ); + const props = { + ...createDataTablesPaneProps(456), + canDownload: false, + }; + render(, { + useRedux: true, + }); + userEvent.click(screen.getByText('Results')); + expect(await screen.findByText('1 row')).toBeVisible(); + expect(screen.queryByLabelText('Copy')).not.toBeInTheDocument(); + fetchMock.restore(); + }); + test('Search table', async () => { fetchMock.post( 'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A789%7D', diff --git a/superset-frontend/src/explore/components/DataTablesPane/test/fixture.tsx b/superset-frontend/src/explore/components/DataTablesPane/test/fixture.tsx index ee766acd6ad4..c09419e7d0c0 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/test/fixture.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/test/fixture.tsx @@ -73,6 +73,7 @@ export const createDataTablesPaneProps = (sliceId: number) => chartStatus: 'rendered' as ChartStatus, onCollapseChange: jest.fn(), actions: exploreActions, + canDownload: true, }) as DataTablesPaneProps; export const createSamplesPaneProps = ({ @@ -90,6 +91,7 @@ export const createSamplesPaneProps = ({ queryForce, isVisible: true, actions: exploreActions, + canDownload: true, }) as SamplesPaneProps; export const createResultsPaneOnDashboardProps = ({ @@ -116,4 +118,5 @@ export const createResultsPaneOnDashboardProps = ({ isVisible: true, actions: exploreActions, errorMessage, + canDownload: true, }) as ResultsPaneProps; diff --git a/superset-frontend/src/explore/components/DataTablesPane/types.ts b/superset-frontend/src/explore/components/DataTablesPane/types.ts index 080388e256eb..5e62e21fa303 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/types.ts +++ b/superset-frontend/src/explore/components/DataTablesPane/types.ts @@ -40,6 +40,7 @@ export interface DataTablesPaneProps { onCollapseChange: (isOpen: boolean) => void; errorMessage?: JSX.Element; actions: ExploreActions; + canDownload: boolean; } export interface ResultsPaneProps { @@ -52,6 +53,7 @@ export interface ResultsPaneProps { dataSize?: number; // reload OriginalFormattedTimeColumns from localStorage when isVisible is true isVisible: boolean; + canDownload: boolean; } export interface SamplesPaneProps { @@ -62,6 +64,7 @@ export interface SamplesPaneProps { dataSize?: number; // reload OriginalFormattedTimeColumns from localStorage when isVisible is true isVisible: boolean; + canDownload: boolean; } export interface TableControlsProps { @@ -73,6 +76,7 @@ export interface TableControlsProps { columnTypes: GenericDataType[]; isLoading: boolean; rowcount: number; + canDownload: boolean; } export interface QueryResultInterface { @@ -88,4 +92,5 @@ export interface SingleQueryResultPaneProp extends QueryResultInterface { dataSize?: number; // reload OriginalFormattedTimeColumns from localStorage when isVisible is true isVisible: boolean; + canDownload: boolean; } diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx index fc28b4e8f6ec..f064643a22d4 100644 --- a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx +++ b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx @@ -394,11 +394,25 @@ describe('Additional actions tests', () => { spyExportChart.restore(); }); - test('Should export to JSON', async () => { + test('Should not export to JSON if canDownload=false', async () => { const props = createProps(); render(, { useRedux: true, }); + userEvent.click(screen.getByLabelText('Menu actions trigger')); + userEvent.hover(screen.getByText('Download')); + const exportJsonElement = await screen.findByText('Export to .JSON'); + userEvent.click(exportJsonElement); + expect(spyExportChart.callCount).toBe(0); + spyExportChart.restore(); + }); + + test('Should export to JSON if canDownload=true', async () => { + const props = createProps(); + props.canDownload = true; + render(, { + useRedux: true, + }); userEvent.click(screen.getByLabelText('Menu actions trigger')); userEvent.hover(screen.getByText('Download')); @@ -407,6 +421,22 @@ describe('Additional actions tests', () => { expect(spyExportChart.callCount).toBe(1); }); + test('Should not export to pivoted CSV if canDownloadCSV=false and viz_type=pivot_table_v2', async () => { + const props = createProps(); + props.chart.latestQueryFormData.viz_type = 'pivot_table_v2'; + render(, { + useRedux: true, + }); + + userEvent.click(screen.getByLabelText('Menu actions trigger')); + userEvent.hover(screen.getByText('Download')); + const exportCSVElement = await screen.findByText( + 'Export to pivoted .CSV', + ); + userEvent.click(exportCSVElement); + expect(spyExportChart.callCount).toBe(0); + }); + test('Should export to pivoted CSV if canDownloadCSV=true and viz_type=pivot_table_v2', async () => { const props = createProps(); props.canDownload = true; @@ -423,5 +453,31 @@ describe('Additional actions tests', () => { userEvent.click(exportCSVElement); expect(spyExportChart.callCount).toBe(1); }); + + test('Should not export to Excel if canDownload=false', async () => { + const props = createProps(); + render(, { + useRedux: true, + }); + userEvent.click(screen.getByLabelText('Menu actions trigger')); + userEvent.hover(screen.getByText('Download')); + const exportExcelElement = await screen.findByText('Export to Excel'); + userEvent.click(exportExcelElement); + expect(spyExportChart.callCount).toBe(0); + spyExportChart.restore(); + }); + + test('Should export to Excel if canDownload=true', async () => { + const props = createProps(); + props.canDownload = true; + render(, { + useRedux: true, + }); + userEvent.click(screen.getByLabelText('Menu actions trigger')); + userEvent.hover(screen.getByText('Download')); + const exportExcelElement = await screen.findByText('Export to Excel'); + userEvent.click(exportExcelElement); + expect(spyExportChart.callCount).toBe(1); + }); }); }); diff --git a/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx b/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx index bf95495c61b3..189177019c70 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx @@ -137,6 +137,7 @@ const ExploreChartPanel = ({ standalone, chartIsStale, chartAlert, + can_download: canDownload, }) => { const theme = useTheme(); const gutterMargin = theme.gridUnit * GUTTER_SIZE_FACTOR; @@ -449,6 +450,7 @@ const ExploreChartPanel = ({ chartStatus={chart.chartStatus} errorMessage={errorMessage} actions={actions} + canDownload={canDownload} /> {showDatasetModal && ( diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx index a40d0f5e3009..448836254cb4 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx @@ -173,22 +173,26 @@ export const useExploreAdditionalActionsMenu = ( const exportJson = useCallback( () => - exportChart({ - formData: latestQueryFormData, - resultType: 'results', - resultFormat: 'json', - }), - [latestQueryFormData], + canDownloadCSV + ? exportChart({ + formData: latestQueryFormData, + resultType: 'results', + resultFormat: 'json', + }) + : null, + [canDownloadCSV, latestQueryFormData], ); const exportExcel = useCallback( () => - exportChart({ - formData: latestQueryFormData, - resultType: 'results', - resultFormat: 'xlsx', - }), - [latestQueryFormData], + canDownloadCSV + ? exportChart({ + formData: latestQueryFormData, + resultType: 'results', + resultFormat: 'xlsx', + }) + : null, + [canDownloadCSV, latestQueryFormData], ); const copyLink = useCallback(async () => { @@ -350,6 +354,7 @@ export const useExploreAdditionalActionsMenu = ( } + disabled={!canDownloadCSV} > {t('Export to .JSON')} @@ -362,6 +367,7 @@ export const useExploreAdditionalActionsMenu = ( } + disabled={!canDownloadCSV} > {t('Export to Excel')}