feat(dashboard): Let users download full CSV of a table (#15046)

* - Convert SliceHeader to TSX in progress
- Add menu option to download full CSV. Probably will change it

* Add Download Full CSV feature, and tests

* Added more tests, more TS fixes

* Added feature flag

* Update @superset-ui package versions

* Update @superset-ui packages versions

* use backend config instead of hardcoding number of rows

* Update tests

* front end test fix

* Lint fixes and test fixes
This commit is contained in:
Ajay M 2021-06-14 17:26:18 -04:00 committed by GitHub
parent 6ed0a3a9e0
commit 045fa1bc7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 578 additions and 433 deletions

File diff suppressed because it is too large Load Diff

View File

@ -67,35 +67,35 @@
"@emotion/babel-preset-css-prop": "^11.2.0",
"@emotion/cache": "^11.1.3",
"@emotion/react": "^11.1.5",
"@superset-ui/chart-controls": "^0.17.55",
"@superset-ui/core": "^0.17.55",
"@superset-ui/legacy-plugin-chart-calendar": "^0.17.55",
"@superset-ui/legacy-plugin-chart-chord": "^0.17.55",
"@superset-ui/legacy-plugin-chart-country-map": "^0.17.55",
"@superset-ui/legacy-plugin-chart-event-flow": "^0.17.55",
"@superset-ui/legacy-plugin-chart-force-directed": "^0.17.55",
"@superset-ui/legacy-plugin-chart-heatmap": "^0.17.55",
"@superset-ui/legacy-plugin-chart-histogram": "^0.17.55",
"@superset-ui/legacy-plugin-chart-horizon": "^0.17.55",
"@superset-ui/legacy-plugin-chart-map-box": "^0.17.55",
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.17.55",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.17.55",
"@superset-ui/legacy-plugin-chart-partition": "^0.17.55",
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.17.55",
"@superset-ui/legacy-plugin-chart-rose": "^0.17.55",
"@superset-ui/legacy-plugin-chart-sankey": "^0.17.55",
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.17.55",
"@superset-ui/legacy-plugin-chart-sunburst": "^0.17.55",
"@superset-ui/legacy-plugin-chart-treemap": "^0.17.55",
"@superset-ui/legacy-plugin-chart-world-map": "^0.17.55",
"@superset-ui/legacy-preset-chart-big-number": "^0.17.55",
"@superset-ui/chart-controls": "^0.17.56",
"@superset-ui/core": "^0.17.56",
"@superset-ui/legacy-plugin-chart-calendar": "^0.17.56",
"@superset-ui/legacy-plugin-chart-chord": "^0.17.56",
"@superset-ui/legacy-plugin-chart-country-map": "^0.17.56",
"@superset-ui/legacy-plugin-chart-event-flow": "^0.17.56",
"@superset-ui/legacy-plugin-chart-force-directed": "^0.17.56",
"@superset-ui/legacy-plugin-chart-heatmap": "^0.17.56",
"@superset-ui/legacy-plugin-chart-histogram": "^0.17.56",
"@superset-ui/legacy-plugin-chart-horizon": "^0.17.56",
"@superset-ui/legacy-plugin-chart-map-box": "^0.17.56",
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.17.56",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.17.56",
"@superset-ui/legacy-plugin-chart-partition": "^0.17.56",
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.17.56",
"@superset-ui/legacy-plugin-chart-rose": "^0.17.56",
"@superset-ui/legacy-plugin-chart-sankey": "^0.17.56",
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.17.56",
"@superset-ui/legacy-plugin-chart-sunburst": "^0.17.56",
"@superset-ui/legacy-plugin-chart-treemap": "^0.17.56",
"@superset-ui/legacy-plugin-chart-world-map": "^0.17.56",
"@superset-ui/legacy-preset-chart-big-number": "^0.17.56",
"@superset-ui/legacy-preset-chart-deckgl": "^0.4.7",
"@superset-ui/legacy-preset-chart-nvd3": "^0.17.55",
"@superset-ui/plugin-chart-echarts": "^0.17.55",
"@superset-ui/plugin-chart-pivot-table": "^0.17.55",
"@superset-ui/plugin-chart-table": "^0.17.55",
"@superset-ui/plugin-chart-word-cloud": "^0.17.55",
"@superset-ui/preset-chart-xy": "^0.17.55",
"@superset-ui/legacy-preset-chart-nvd3": "^0.17.56",
"@superset-ui/plugin-chart-echarts": "^0.17.56",
"@superset-ui/plugin-chart-pivot-table": "^0.17.56",
"@superset-ui/plugin-chart-table": "^0.17.56",
"@superset-ui/plugin-chart-word-cloud": "^0.17.56",
"@superset-ui/preset-chart-xy": "^0.17.56",
"@vx/responsive": "^0.0.195",
"abortcontroller-polyfill": "^1.1.9",
"antd": "^4.9.4",

View File

@ -36,6 +36,7 @@ import { sliceId } from 'spec/fixtures/mockChartQueries';
import dashboardInfo from 'spec/fixtures/mockDashboardInfo';
import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout';
import { sliceEntitiesForChart } from 'spec/fixtures/mockSliceEntities';
import { initialState } from 'spec/javascripts/sqllab/fixtures';
import { nativeFiltersInfo } from '../../fixtures/mockNativeFilters';
describe('ChartHolder', () => {
@ -61,6 +62,7 @@ describe('ChartHolder', () => {
function setup(overrideProps) {
const mockStore = getMockStore({
...initialState,
sliceEntities: sliceEntitiesForChart,
});

View File

@ -23,7 +23,7 @@ import sinon from 'sinon';
import Chart from 'src/dashboard/components/gridComponents/Chart';
import SliceHeader from 'src/dashboard/components/SliceHeader';
import ChartContainer from 'src/chart/ChartContainer';
import * as exploreUtils from 'src/explore/exploreUtils';
import { sliceEntitiesForChart as sliceEntities } from 'spec/fixtures/mockSliceEntities';
import mockDatasource from 'spec/fixtures/mockDatasource';
import chartQueries, {
@ -38,6 +38,7 @@ describe('Chart', () => {
updateSliceName() {},
// from redux
maxRows: 666,
chart: chartQueries[queryId],
formData: chartQueries[queryId].formData,
datasource: mockDatasource[sliceEntities.slices[queryId].datasource],
@ -59,6 +60,8 @@ describe('Chart', () => {
unsetFocusedFilterField() {},
addSuccessToast() {},
addDangerToast() {},
exportCSV() {},
exportFullCSV() {},
componentId: 'test',
dashboardId: 111,
editMode: false,
@ -86,7 +89,6 @@ describe('Chart', () => {
it('should render a description if it has one and isExpanded=true', () => {
const wrapper = setup();
expect(wrapper.find('.slice_description')).not.toExist();
wrapper.setProps({ ...props, isExpanded: true });
expect(wrapper.find('.slice_description')).toExist();
});
@ -104,4 +106,30 @@ describe('Chart', () => {
wrapper.instance().changeFilter();
expect(changeFilter.callCount).toBe(1);
});
it('should call exportChart when exportCSV is clicked', () => {
const stubbedExportCSV = sinon
.stub(exploreUtils, 'exportChart')
.returns(() => {});
const wrapper = setup();
wrapper.instance().exportCSV(props.slice.sliceId);
expect(stubbedExportCSV.calledOnce).toBe(true);
expect(stubbedExportCSV.lastCall.args[0]).toEqual(
expect.objectContaining({
formData: expect.anything(),
resultType: 'results',
resultFormat: 'csv',
}),
);
exploreUtils.exportChart.restore();
});
it('should call exportChart with row_limit props.maxRows when exportFullCSV is clicked', () => {
const stubbedExportCSV = sinon
.stub(exploreUtils, 'exportChart')
.returns(() => {});
const wrapper = setup();
wrapper.instance().exportFullCSV(props.slice.sliceId);
expect(stubbedExportCSV.calledOnce).toBe(true);
expect(stubbedExportCSV.lastCall.args[0].formData.row_limit).toEqual(666);
exploreUtils.exportChart.restore();
});
});

View File

@ -34,8 +34,9 @@ import IconButton from 'src/dashboard/components/IconButton';
import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer';
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import { mockStore } from 'spec/fixtures/mockStore';
import { getMockStore } from 'spec/fixtures/mockStore';
import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout';
import { initialState } from 'spec/javascripts/sqllab/fixtures';
describe('Column', () => {
const columnWithoutChildren = {
@ -65,6 +66,9 @@ describe('Column', () => {
function setup(overrideProps) {
// We have to wrap provide DragDropContext for the underlying DragDroppable
// otherwise we cannot assert on DragDroppable children
const mockStore = getMockStore({
...initialState,
});
const wrapper = mount(
<Provider store={mockStore}>
<DndProvider backend={HTML5Backend}>

View File

@ -34,8 +34,9 @@ import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import { DASHBOARD_GRID_ID } from 'src/dashboard/util/constants';
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
import { mockStore } from 'spec/fixtures/mockStore';
import { getMockStore } from 'spec/fixtures/mockStore';
import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout';
import { initialState } from 'spec/javascripts/sqllab/fixtures';
describe('Row', () => {
const rowWithoutChildren = { ...mockLayout.present.ROW_ID, children: [] };
@ -61,6 +62,9 @@ describe('Row', () => {
function setup(overrideProps) {
// We have to wrap provide DragDropContext for the underlying DragDroppable
// otherwise we cannot assert on DragDroppable children
const mockStore = getMockStore({
...initialState,
});
const wrapper = mount(
<Provider store={mockStore}>
<DndProvider backend={HTML5Backend}>

View File

@ -31,7 +31,8 @@ import Tab, {
RENDER_TAB_CONTENT,
} from 'src/dashboard/components/gridComponents/Tab';
import { dashboardLayoutWithTabs } from 'spec/fixtures/mockDashboardLayout';
import { mockStoreWithTabs } from 'spec/fixtures/mockStore';
import { getMockStore } from 'spec/fixtures/mockStore';
import { initialState } from 'spec/javascripts/sqllab/fixtures';
describe('Tabs', () => {
const props = {
@ -62,8 +63,13 @@ describe('Tabs', () => {
function setup(overrideProps) {
// We have to wrap provide DragDropContext for the underlying DragDroppable
// otherwise we cannot assert on DragDroppable children
const mockStore = getMockStore({
...initialState,
dashboardLayout: dashboardLayoutWithTabs,
dashboardFilters: {},
});
const wrapper = mount(
<Provider store={mockStoreWithTabs}>
<Provider store={mockStore}>
<DndProvider backend={HTML5Backend}>
<Tab {...props} {...overrideProps} />
</DndProvider>

View File

@ -35,8 +35,9 @@ import Tabs from 'src/dashboard/components/gridComponents/Tabs';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import emptyDashboardLayout from 'src/dashboard/fixtures/emptyDashboardLayout';
import { dashboardLayoutWithTabs } from 'spec/fixtures/mockDashboardLayout';
import { mockStoreWithTabs } from 'spec/fixtures/mockStore';
import { getMockStore } from 'spec/fixtures/mockStore';
import { nativeFilters } from 'spec/fixtures/mockNativeFilters';
import { initialState } from 'spec/javascripts/sqllab/fixtures';
describe('Tabs', () => {
fetchMock.post('glob:*/r/shortner/', {});
@ -68,8 +69,13 @@ describe('Tabs', () => {
function setup(overrideProps) {
// We have to wrap provide DragDropContext for the underlying DragDroppable
// otherwise we cannot assert on DragDroppable children
const mockStore = getMockStore({
...initialState,
dashboardLayout: dashboardLayoutWithTabs,
dashboardFilters: {},
});
const wrapper = mount(
<Provider store={mockStoreWithTabs}>
<Provider store={mockStore}>
<DndProvider backend={HTML5Backend}>
<Tabs {...props} {...overrideProps} />
</DndProvider>

View File

@ -229,7 +229,9 @@ export const TextArea = styled(AntdInput.TextArea)`
border-radius: ${({ theme }) => theme.borderRadius}px;
`;
export const NoAnimationDropdown = (props: DropDownProps) => (
export const NoAnimationDropdown = (
props: DropDownProps & { children?: React.ReactNode },
) => (
<Dropdown
overlayStyle={{ zIndex: 4000, animationDuration: '0s' }}
{...props}

View File

@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Slice } from 'src/types/Chart';
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
@ -114,7 +113,7 @@ const createProps = () => ({
supersetCanExplore: true,
supersetCanCSV: true,
sliceCanEdit: false,
slice: ({
slice: {
slice_id: 312,
slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20312%7D',
slice_name: 'Vaccine Candidates per Phase',
@ -139,12 +138,13 @@ const createProps = () => ({
},
viz_type: 'dist_bar',
datasource: '58__table',
description: null,
description: '',
description_markeddown: '',
owners: [],
modified: '<span class="no-wrap">20 hours ago</span>',
changed_on: 1617143411366,
} as unknown) as Slice,
slice_description: '',
},
componentId: 'CHART-aGfmWtliqA',
dashboardId: 26,
isFullSize: false,

View File

@ -25,21 +25,27 @@ import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls';
import FiltersBadge from 'src/dashboard/containers/FiltersBadge';
import Icon from 'src/components/Icon';
import { RootState } from 'src/dashboard/types';
import { Slice } from 'src/types/Chart';
import FilterIndicator from 'src/dashboard/components/FiltersBadge/FilterIndicator';
type SliceHeaderProps = {
innerRef?: string;
slice: Slice;
slice: {
description: string;
viz_type: string;
slice_name: string;
slice_id: number;
slice_description: string;
};
isExpanded?: boolean;
isCached?: boolean[];
cachedDttm?: string[];
updatedDttm?: number;
updateSliceName?: (arg0: string) => void;
toggleExpandSlice?: Function;
forceRefresh?: Function;
exploreChart?: Function;
exportCSV?: Function;
toggleExpandSlice?: () => void;
forceRefresh?: () => void;
exploreChart?: () => void;
exportCSV?: () => void;
exportFullCSV?: () => void;
editMode?: boolean;
isFullSize?: boolean;
annotationQuery?: object;
@ -52,9 +58,9 @@ type SliceHeaderProps = {
componentId: string;
dashboardId: number;
filters: object;
addSuccessToast: Function;
addDangerToast: Function;
handleToggleFullSize: Function;
addSuccessToast: () => void;
addDangerToast: () => void;
handleToggleFullSize: () => void;
chartStatus: string;
formData: object;
};
@ -82,12 +88,13 @@ const SliceHeader: FC<SliceHeaderProps> = ({
cachedDttm = null,
updatedDttm = null,
isCached = [],
isExpanded = [],
isExpanded = false,
sliceName = '',
supersetCanExplore = false,
supersetCanShare = false,
supersetCanCSV = false,
sliceCanEdit = false,
exportFullCSV,
slice,
componentId,
dashboardId,
@ -174,6 +181,7 @@ const SliceHeader: FC<SliceHeaderProps> = ({
forceRefresh={forceRefresh}
exploreChart={exploreChart}
exportCSV={exportCSV}
exportFullCSV={exportFullCSV}
supersetCanExplore={supersetCanExplore}
supersetCanShare={supersetCanShare}
supersetCanCSV={supersetCanCSV}

View File

@ -20,6 +20,7 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import { FeatureFlag } from 'src/featureFlags';
import SliceHeaderControls from '.';
jest.mock('src/common/components', () => {
@ -40,6 +41,7 @@ const createProps = () => ({
addSuccessToast: jest.fn(),
exploreChart: jest.fn(),
exportCSV: jest.fn(),
exportFullCSV: jest.fn(),
forceRefresh: jest.fn(),
handleToggleFullSize: jest.fn(),
toggleExpandSlice: jest.fn(),
@ -47,6 +49,7 @@ const createProps = () => ({
slice_id: 371,
slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20371%7D',
slice_name: 'Vaccine Candidates per Country & Stage',
slice_description: 'Table of vaccine candidates for 100 countries',
form_data: {
adhoc_filters: [],
color_scheme: 'supersetColors',
@ -76,7 +79,7 @@ const createProps = () => ({
},
isCached: [false],
isExpanded: false,
cachedDttm: [null],
cachedDttm: [''],
updatedDttm: 1617213803803,
supersetCanExplore: true,
supersetCanCSV: true,
@ -85,6 +88,9 @@ const createProps = () => ({
dashboardId: 26,
isFullSize: false,
chartStatus: 'rendered',
showControls: true,
supersetCanShare: true,
formData: {},
});
test('Should render', () => {
@ -119,7 +125,6 @@ test('Should render default props', () => {
delete props.sliceCanEdit;
render(<SliceHeaderControls {...props} />, { useRedux: true });
userEvent.click(screen.getByRole('menuitem', { name: 'Maximize chart' }));
userEvent.click(screen.getByRole('menuitem', { name: /Force refresh/ }));
userEvent.click(
@ -147,6 +152,47 @@ test('Should "export to CSV"', () => {
expect(props.exportCSV).toBeCalledWith(371);
});
test('Export full CSV is under featureflag', () => {
// @ts-ignore
global.featureFlags = {
[FeatureFlag.ALLOW_FULL_CSV_EXPORT]: false,
};
const props = createProps();
props.slice.viz_type = 'table';
render(<SliceHeaderControls {...props} />, { useRedux: true });
expect(screen.queryByRole('menuitem', { name: 'Export full CSV' })).toBe(
null,
);
});
test('Should "export full CSV"', () => {
// @ts-ignore
global.featureFlags = {
[FeatureFlag.ALLOW_FULL_CSV_EXPORT]: true,
};
const props = createProps();
props.slice.viz_type = 'table';
render(<SliceHeaderControls {...props} />, { useRedux: true });
expect(screen.queryByRole('menuitem', { name: 'Export full CSV' })).not.toBe(
null,
);
expect(props.exportFullCSV).toBeCalledTimes(0);
userEvent.click(screen.getByRole('menuitem', { name: 'Export full CSV' }));
expect(props.exportFullCSV).toBeCalledTimes(1);
expect(props.exportFullCSV).toBeCalledWith(371);
});
test('Should not show export full CSV if report is not table', () => {
// @ts-ignore
global.featureFlags = {
[FeatureFlag.ALLOW_FULL_CSV_EXPORT]: true,
};
const props = createProps();
render(<SliceHeaderControls {...props} />, { useRedux: true });
expect(screen.queryByRole('menuitem', { name: 'Export full CSV' })).toBe(
null,
);
});
test('Should "View chart in Explore"', () => {
const props = createProps();
render(<SliceHeaderControls {...props} />, { useRedux: true });

View File

@ -17,7 +17,6 @@
* under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
import {
Behavior,
@ -36,48 +35,15 @@ import Icons from 'src/components/Icons';
import ModalTrigger from 'src/components/ModalTrigger';
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
const propTypes = {
slice: PropTypes.object.isRequired,
componentId: PropTypes.string.isRequired,
dashboardId: PropTypes.number.isRequired,
addDangerToast: PropTypes.func.isRequired,
isCached: PropTypes.arrayOf(PropTypes.bool),
cachedDttm: PropTypes.arrayOf(PropTypes.string),
isExpanded: PropTypes.bool,
updatedDttm: PropTypes.number,
supersetCanExplore: PropTypes.bool,
supersetCanShare: PropTypes.bool,
supersetCanCSV: PropTypes.bool,
sliceCanEdit: PropTypes.bool,
toggleExpandSlice: PropTypes.func,
forceRefresh: PropTypes.func,
exploreChart: PropTypes.func,
exportCSV: PropTypes.func,
};
const defaultProps = {
forceRefresh: () => ({}),
toggleExpandSlice: () => ({}),
exploreChart: () => ({}),
exportCSV: () => ({}),
cachedDttm: [],
updatedDttm: null,
isCached: [],
isExpanded: false,
supersetCanExplore: false,
supersetCanShare: false,
supersetCanCSV: false,
sliceCanEdit: false,
};
const MENU_KEYS = {
CROSS_FILTER_SCOPING: 'cross_filter_scoping',
FORCE_REFRESH: 'force_refresh',
TOGGLE_CHART_DESCRIPTION: 'toggle_chart_description',
DOWNLOAD_AS_IMAGE: 'download_as_image',
EXPLORE_CHART: 'explore_chart',
EXPORT_CSV: 'export_csv',
EXPORT_FULL_CSV: 'export_full_csv',
FORCE_REFRESH: 'force_refresh',
RESIZE_LABEL: 'resize_label',
DOWNLOAD_AS_IMAGE: 'download_as_image',
TOGGLE_CHART_DESCRIPTION: 'toggle_chart_description',
VIEW_QUERY: 'view_query',
};
@ -114,9 +80,43 @@ const VerticalDotsTrigger = () => (
<span className="dot" />
</VerticalDotsContainer>
);
interface Props {
slice: {
description: string;
viz_type: string;
slice_name: string;
slice_id: number;
slice_description: string;
};
componentId: string;
chartStatus: string;
dashboardId: number;
addDangerToast: () => void;
isCached: boolean[];
cachedDttm: string[] | null;
isExpanded?: boolean;
updatedDttm: number | null;
supersetCanExplore: boolean;
supersetCanShare: boolean;
supersetCanCSV: boolean;
sliceCanEdit: boolean;
isFullSize?: boolean;
formData: object;
toggleExpandSlice?: (sliceId: number) => void;
forceRefresh: (sliceId: number, dashboardId: number) => void;
exploreChart?: (sliceId: number) => void;
exportCSV?: (sliceId: number) => void;
exportFullCSV?: (sliceId: number) => void;
addSuccessToast: (message: string) => void;
handleToggleFullSize: () => void;
}
interface State {
showControls: boolean;
showCrossFilterScopingModal: boolean;
}
class SliceHeaderControls extends React.PureComponent {
constructor(props) {
class SliceHeaderControls extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.toggleControls = this.toggleControls.bind(this);
this.refreshChart = this.refreshChart.bind(this);
@ -143,7 +143,13 @@ class SliceHeaderControls extends React.PureComponent {
}));
}
handleMenuClick({ key, domEvent }) {
handleMenuClick({
key,
domEvent,
}: {
key: React.Key;
domEvent: React.MouseEvent<HTMLElement>;
}) {
switch (key) {
case MENU_KEYS.FORCE_REFRESH:
this.refreshChart();
@ -152,27 +158,38 @@ class SliceHeaderControls extends React.PureComponent {
this.setState({ showCrossFilterScopingModal: true });
break;
case MENU_KEYS.TOGGLE_CHART_DESCRIPTION:
this.props.toggleExpandSlice(this.props.slice.slice_id);
// eslint-disable-next-line no-unused-expressions
this.props.toggleExpandSlice &&
this.props.toggleExpandSlice(this.props.slice.slice_id);
break;
case MENU_KEYS.EXPLORE_CHART:
this.props.exploreChart(this.props.slice.slice_id);
// eslint-disable-next-line no-unused-expressions
this.props.exploreChart &&
this.props.exploreChart(this.props.slice.slice_id);
break;
case MENU_KEYS.EXPORT_CSV:
this.props.exportCSV(this.props.slice.slice_id);
// eslint-disable-next-line no-unused-expressions
this.props.exportCSV && this.props.exportCSV(this.props.slice.slice_id);
break;
case MENU_KEYS.RESIZE_LABEL:
this.props.handleToggleFullSize();
break;
case MENU_KEYS.EXPORT_FULL_CSV:
// eslint-disable-next-line no-unused-expressions
this.props.exportFullCSV &&
this.props.exportFullCSV(this.props.slice.slice_id);
break;
case MENU_KEYS.DOWNLOAD_AS_IMAGE: {
// menu closes with a delay, we need to hide it manually,
// so that we don't capture it on the screenshot
const menu = document.querySelector(
'.ant-dropdown:not(.ant-dropdown-hidden)',
);
) as HTMLElement;
menu.style.visibility = 'hidden';
downloadAsImage(
SCREENSHOT_NODE_SELECTOR,
this.props.slice.slice_name,
// @ts-ignore
)(domEvent).then(() => {
menu.style.visibility = 'visible';
});
@ -186,16 +203,17 @@ class SliceHeaderControls extends React.PureComponent {
render() {
const {
slice,
isCached,
cachedDttm,
updatedDttm,
componentId,
addSuccessToast,
addDangerToast,
isFullSize,
supersetCanShare,
componentId,
cachedDttm = [],
updatedDttm = null,
addSuccessToast = () => {},
addDangerToast = () => {},
supersetCanShare = false,
isCached = [],
} = this.props;
const crossFilterItems = getChartMetadataRegistry().items;
const isTable = slice.viz_type === 'table';
const isCrossFilter = Object.entries(crossFilterItems)
// @ts-ignore
.filter(([, { value }]) =>
@ -203,11 +221,11 @@ class SliceHeaderControls extends React.PureComponent {
)
.find(([key]) => key === slice.viz_type);
const cachedWhen = cachedDttm.map(itemCachedDttm =>
const cachedWhen = (cachedDttm || []).map(itemCachedDttm =>
moment.utc(itemCachedDttm).fromNow(),
);
const updatedWhen = updatedDttm ? moment.utc(updatedDttm).fromNow() : '';
const getCachedTitle = itemCached => {
const getCachedTitle = (itemCached: boolean) => {
if (itemCached) {
return t('Cached %s', cachedWhen);
}
@ -216,12 +234,11 @@ class SliceHeaderControls extends React.PureComponent {
}
return '';
};
const refreshTooltipData = isCached.map(getCachedTitle) || '';
const refreshTooltipData = [...new Set(isCached.map(getCachedTitle) || '')];
// If all queries have same cache time we can unit them to one
let refreshTooltip = [...new Set(refreshTooltipData)];
refreshTooltip = refreshTooltip.map((item, index) => (
const refreshTooltip = refreshTooltipData.map((item, index) => (
<div key={`tooltip-${index}`}>
{refreshTooltip.length > 1
{refreshTooltipData.length > 1
? `${t('Query')} ${index + 1}: ${item}`
: item}
</div>
@ -299,6 +316,13 @@ class SliceHeaderControls extends React.PureComponent {
{this.props.supersetCanCSV && (
<Menu.Item key={MENU_KEYS.EXPORT_CSV}>{t('Export CSV')}</Menu.Item>
)}
{isFeatureEnabled(FeatureFlag.ALLOW_FULL_CSV_EXPORT) &&
this.props.supersetCanCSV &&
isTable && (
<Menu.Item key={MENU_KEYS.EXPORT_FULL_CSV}>
{t('Export full CSV')}
</Menu.Item>
)}
{isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS) &&
isCrossFilter && (
<Menu.Item key={MENU_KEYS.CROSS_FILTER_SCOPING}>
@ -327,11 +351,8 @@ class SliceHeaderControls extends React.PureComponent {
overlay={menu}
trigger={['click']}
placement="bottomRight"
dropdownAlign={{
offset: [-40, 4],
}}
getPopupContainer={triggerNode =>
triggerNode.closest(SCREENSHOT_NODE_SELECTOR)
triggerNode.closest(SCREENSHOT_NODE_SELECTOR) as HTMLElement
}
>
<span
@ -347,7 +368,4 @@ class SliceHeaderControls extends React.PureComponent {
}
}
SliceHeaderControls.propTypes = propTypes;
SliceHeaderControls.defaultProps = defaultProps;
export default SliceHeaderControls;

View File

@ -53,6 +53,7 @@ const propTypes = {
slice: slicePropShape.isRequired,
sliceName: PropTypes.string.isRequired,
timeout: PropTypes.number.isRequired,
maxRows: PropTypes.number.isRequired,
// all active filter fields in dashboard
filters: PropTypes.object.isRequired,
refreshChart: PropTypes.func.isRequired,
@ -109,6 +110,7 @@ export default class Chart extends React.Component {
this.handleFilterMenuClose = this.handleFilterMenuClose.bind(this);
this.exploreChart = this.exploreChart.bind(this);
this.exportCSV = this.exportCSV.bind(this);
this.exportFullCSV = this.exportFullCSV.bind(this);
this.forceRefresh = this.forceRefresh.bind(this);
this.resize = this.resize.bind(this);
this.setDescriptionRef = this.setDescriptionRef.bind(this);
@ -225,18 +227,24 @@ export default class Chart extends React.Component {
exploreChart(this.props.formData);
}
exportCSV() {
exportCSV(isFullCSV = false) {
this.props.logEvent(LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART, {
slice_id: this.props.slice.slice_id,
is_cached: this.props.isCached,
});
exportChart({
formData: this.props.formData,
formData: isFullCSV
? { ...this.props.formData, row_limit: this.props.maxRows }
: this.props.formData,
resultType: 'results',
resultFormat: 'csv',
});
}
exportFullCSV() {
this.exportCSV(true);
}
forceRefresh() {
this.props.logEvent(LOG_ACTIONS_FORCE_REFRESH_CHART, {
slice_id: this.props.slice.slice_id,
@ -278,7 +286,6 @@ export default class Chart extends React.Component {
} = this.props;
const { width } = this.state;
// this prevents throwing in the case that a gridComponent
// references a chart that is not associated with the dashboard
if (!chart || !slice) {
@ -320,6 +327,7 @@ export default class Chart extends React.Component {
annotationQuery={chart.annotationQuery}
exploreChart={this.exploreChart}
exportCSV={this.exportCSV}
exportFullCSV={this.exportFullCSV}
updateSliceName={updateSliceName}
sliceName={sliceName}
supersetCanExplore={supersetCanExplore}

View File

@ -20,13 +20,17 @@
import React from 'react';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/react';
import { render, screen } from 'spec/helpers/testing-library';
import mockState from 'spec/fixtures/mockState';
import { sliceId as chartId } from 'spec/fixtures/mockChartQueries';
import { nativeFiltersInfo } from 'spec/javascripts/dashboard/fixtures/mockNativeFilters';
import newComponentFactory from 'src/dashboard/util/newComponentFactory';
import { ChartHolder } from './index';
import { getMockStore } from 'spec/fixtures/mockStore';
import { initialState } from 'spec/javascripts/sqllab/fixtures';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { Provider } from 'react-redux';
import { screen, render } from 'spec/helpers/testing-library';
import { CHART_TYPE, ROW_TYPE } from '../../util/componentTypes';
import { ChartHolder } from './index';
describe('ChartHolder', () => {
const defaultProps = {
@ -63,13 +67,17 @@ describe('ChartHolder', () => {
dashboardId: 123,
nativeFilters: nativeFiltersInfo.filters,
};
const renderWrapper = (props = defaultProps, state = mockState) =>
render(<ChartHolder {...props} />, {
useRedux: true,
initialState: state,
useDnd: true,
});
const mockStore = getMockStore({
...initialState,
});
const renderWrapper = () =>
render(
<Provider store={mockStore}>
<DndProvider backend={HTML5Backend}>
<ChartHolder {...defaultProps} />{' '}
</DndProvider>
</Provider>,
);
it('toggle full size', async () => {
renderWrapper();

View File

@ -48,6 +48,7 @@ function mapStateToProps(
datasources,
sliceEntities,
nativeFilters,
common,
},
ownProps,
) {
@ -89,6 +90,7 @@ function mapStateToProps(
sliceCanEdit: !!dashboardInfo.slice_can_edit,
ownState: dataMask[id]?.ownState,
filterState: dataMask[id]?.filterState,
maxRows: common.conf.SQL_MAX_ROW,
};
}

View File

@ -386,6 +386,9 @@ DEFAULT_FEATURE_FLAGS: Dict[str, bool] = {
# Enabling FORCE_DATABASE_CONNECTIONS_SSL forces all database connections to be
# encrypted before being saved into superset metastore.
"FORCE_DATABASE_CONNECTIONS_SSL": False,
# Allow users to export full CSV of table viz type.
# This could cause the server to run out of memory or compute.
"ALLOW_FULL_CSV_EXPORT": False,
}
# Feature flags may also be set via 'SUPERSET_FEATURE_' prefixed environment vars.