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:
parent
6ed0a3a9e0
commit
045fa1bc7a
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue