From b0b0ec0638029d3cf1dd1559927cff9f882a966c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CA=88=E1=B5=83=E1=B5=A2?= Date: Thu, 16 Jul 2020 16:07:49 -0700 Subject: [PATCH] feat(listviews): SIP-34 Bulk Select (#10298) --- superset-frontend/jest.config.js | 1 + .../spec/helpers/waitForComponentToPaint.ts | 34 +++ .../components/ListView/ListView_spec.jsx | 70 ++++-- .../dashboard/components/CodeModal_spec.jsx | 6 +- .../views/datasetList/DatasetList_spec.jsx | 81 ++++++- .../components/RunQueryActionButton.tsx | 4 +- .../src/components/{Button.jsx => Button.tsx} | 78 +++++-- .../src/components/ListView/ListView.tsx | 132 ++++++++---- .../components/ListView/ListViewStyles.less | 9 + .../components/ListView/TableCollection.tsx | 6 +- .../src/components/ListView/utils.ts | 2 + .../src/components/Menu/Menu.jsx | 3 +- .../src/components/Menu/SubMenu.tsx | 113 +++++----- .../src/views/chartList/ChartList.tsx | 56 +++-- .../src/views/dashboardList/DashboardList.tsx | 65 +++--- .../{DatasetModal.tsx => AddDatasetModal.tsx} | 16 +- .../src/views/datasetList/DatasetList.tsx | 199 +++++++++++------- superset-frontend/webpack.config.js | 1 + 18 files changed, 590 insertions(+), 286 deletions(-) create mode 100644 superset-frontend/spec/helpers/waitForComponentToPaint.ts rename superset-frontend/src/components/{Button.jsx => Button.tsx} (50%) rename superset-frontend/src/views/datasetList/{DatasetModal.tsx => AddDatasetModal.tsx} (92%) diff --git a/superset-frontend/jest.config.js b/superset-frontend/jest.config.js index ea190d125029a..18b05ccd0710b 100644 --- a/superset-frontend/jest.config.js +++ b/superset-frontend/jest.config.js @@ -23,6 +23,7 @@ module.exports = { '\\.(gif|ttf|eot)$': '/spec/__mocks__/fileMock.js', '\\.svg$': '/spec/__mocks__/svgrMock.js', '^src/(.*)$': '/src/$1', + '^spec/(.*)$': '/spec/$1', }, setupFilesAfterEnv: ['/spec/helpers/shim.js'], testURL: 'http://localhost', diff --git a/superset-frontend/spec/helpers/waitForComponentToPaint.ts b/superset-frontend/spec/helpers/waitForComponentToPaint.ts new file mode 100644 index 0000000000000..2e57a80b413c8 --- /dev/null +++ b/superset-frontend/spec/helpers/waitForComponentToPaint.ts @@ -0,0 +1,34 @@ +/** + * 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 { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; + +// taken from: https://github.com/enzymejs/enzyme/issues/2073 +// There is currently and issue with enzyme and react-16's hooks +// that results in a race condition between tests and react hook updates. +// This function ensures tests run after all react updates are done. +export default async function waitForComponentToPaint

( + wrapper: ReactWrapper

, + amount = 0, +) { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, amount)); + wrapper.update(); + }); +} diff --git a/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx b/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx index bb7b2d8e84ef8..6f62e4ef089be 100644 --- a/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx +++ b/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx @@ -22,13 +22,17 @@ import { act } from 'react-dom/test-utils'; import { MenuItem } from 'react-bootstrap'; import Select from 'src/components/Select'; import { QueryParamProvider } from 'use-query-params'; +import { supersetTheme, ThemeProvider } from '@superset-ui/style'; import ListView from 'src/components/ListView/ListView'; import ListViewFilters from 'src/components/ListView/Filters'; import ListViewPagination from 'src/components/ListView/Pagination'; import Pagination from 'src/components/Pagination'; +import Button from 'src/components/Button'; import { areArraysShallowEqual } from 'src/reduxUtils'; -import { supersetTheme, ThemeProvider } from '@superset-ui/style'; + +import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; +import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; function makeMockLocation(query) { const queryStr = encodeURIComponent(query); @@ -72,8 +76,15 @@ const mockedProps = { pageSize: 1, fetchData: jest.fn(() => []), loading: false, + bulkSelectEnabled: true, + disableBulkSelect: jest.fn(), bulkActions: [ - { key: 'something', name: 'do something', onSelect: jest.fn() }, + { + key: 'something', + name: 'do something', + style: 'danger', + onSelect: jest.fn(), + }, ], }; @@ -89,7 +100,10 @@ const factory = (props = mockedProps) => ); describe('ListView', () => { - const wrapper = factory(); + let wrapper = beforeAll(async () => { + wrapper = factory(); + await waitForComponentToPaint(wrapper); + }); afterEach(() => { mockedProps.fetchData.mockClear(); @@ -227,18 +241,17 @@ Array [ wrapper.find('input[id="0"]').at(0).prop('onChange')({ target: { value: 'on' }, }); + }); + wrapper.update(); + act(() => { wrapper - .find('.dropdown-toggle') - .children('button') - .at(1) + .find('[data-test="bulk-select-controls"]') + .find(Button) .props() .onClick(); }); - wrapper.update(); - const bulkActionsProps = wrapper.find(MenuItem).last().props(); - bulkActionsProps.onSelect(bulkActionsProps.eventKey); expect(mockedProps.bulkActions[0].onSelect.mock.calls[0]) .toMatchInlineSnapshot(` Array [ @@ -257,18 +270,17 @@ Array [ wrapper.find('input[id="header-toggle-all"]').at(0).prop('onChange')({ target: { value: 'on' }, }); + }); + wrapper.update(); + act(() => { wrapper - .find('.dropdown-toggle') - .children('button') - .at(1) + .find('[data-test="bulk-select-controls"]') + .find(Button) .props() .onClick(); }); - wrapper.update(); - const bulkActionsProps = wrapper.find(MenuItem).last().props(); - bulkActionsProps.onSelect(bulkActionsProps.eventKey); expect(mockedProps.bulkActions[0].onSelect.mock.calls[0]) .toMatchInlineSnapshot(` Array [ @@ -286,6 +298,34 @@ Array [ `); }); + it('allows deselecting all', async () => { + act(() => { + wrapper.find('[data-test="bulk-select-deselect-all"]').props().onClick(); + }); + await waitForComponentToPaint(wrapper); + wrapper.update(); + wrapper.find(IndeterminateCheckbox).forEach(input => { + expect(input.props().checked).toBe(false); + }); + }); + + it('allows disabling bulkSelect', () => { + wrapper + .find('[data-test="bulk-select-controls"]') + .at(0) + .props() + .onDismiss(); + expect(mockedProps.disableBulkSelect).toHaveBeenCalled(); + }); + + it('disables bulk select based on prop', async () => { + const wrapper2 = factory({ ...mockedProps, bulkSelectEnabled: false }); + await waitForComponentToPaint(wrapper2); + expect(wrapper2.find('[data-test="bulk-select-controls"]').exists()).toBe( + false, + ); + }); + it('Throws an exception if filter missing in columns', () => { expect.assertions(1); const props = { diff --git a/superset-frontend/spec/javascripts/dashboard/components/CodeModal_spec.jsx b/superset-frontend/spec/javascripts/dashboard/components/CodeModal_spec.jsx index 297492e3412b2..1589dd33b64a6 100644 --- a/superset-frontend/spec/javascripts/dashboard/components/CodeModal_spec.jsx +++ b/superset-frontend/spec/javascripts/dashboard/components/CodeModal_spec.jsx @@ -18,6 +18,7 @@ */ import React from 'react'; import { mount } from 'enzyme'; +import { supersetTheme, ThemeProvider } from '@superset-ui/style'; import CodeModal from 'src/dashboard/components/CodeModal'; @@ -29,7 +30,10 @@ describe('CodeModal', () => { expect(React.isValidElement()).toBe(true); }); it('renders the trigger node', () => { - const wrapper = mount(); + const wrapper = mount(, { + wrappingComponent: ThemeProvider, + wrappingComponentProps: { theme: supersetTheme }, + }); expect(wrapper.find('.fa-edit')).toHaveLength(1); }); }); diff --git a/superset-frontend/spec/javascripts/views/datasetList/DatasetList_spec.jsx b/superset-frontend/spec/javascripts/views/datasetList/DatasetList_spec.jsx index 665b2bf7f9224..1ceb5272d7ec5 100644 --- a/superset-frontend/spec/javascripts/views/datasetList/DatasetList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/datasetList/DatasetList_spec.jsx @@ -21,10 +21,14 @@ import { mount } from 'enzyme'; import thunk from 'redux-thunk'; import configureStore from 'redux-mock-store'; import fetchMock from 'fetch-mock'; +import { supersetTheme, ThemeProvider } from '@superset-ui/style'; import DatasetList from 'src/views/datasetList/DatasetList'; import ListView from 'src/components/ListView/ListView'; -import { supersetTheme, ThemeProvider } from '@superset-ui/style'; +import Button from 'src/components/Button'; +import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; +import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; +import { act } from 'react-dom/test-utils'; // store needed for withToasts(datasetTable) const mockStore = configureStore([thunk]); @@ -37,7 +41,7 @@ const datasetsEndpoint = 'glob:*/api/v1/dataset/?*'; const mockdatasets = [...new Array(3)].map((_, i) => ({ changed_by_name: 'user', - kind: ['physical', 'virtual'][Math.floor(Math.random() * 2)], + kind: i === 0 ? 'virtual' : 'physical', // ensure there is 1 virtual changed_by_url: 'changed_by_url', changed_by: 'user', changed_on: new Date().toISOString(), @@ -49,7 +53,7 @@ const mockdatasets = [...new Array(3)].map((_, i) => ({ })); fetchMock.get(datasetsInfoEndpoint, { - permissions: ['can_list', 'can_edit'], + permissions: ['can_list', 'can_edit', 'can_add', 'can_delete'], filters: { database: [], schema: [], @@ -69,13 +73,24 @@ fetchMock.get(databaseEndpoint, { result: [], }); -describe('DatasetList', () => { - const mockedProps = {}; - const wrapper = mount(, { +async function mountAndWait(props) { + const mounted = mount(, { context: { store }, wrappingComponent: ThemeProvider, wrappingComponentProps: { theme: supersetTheme }, }); + await waitForComponentToPaint(mounted); + + return mounted; +} + +describe('DatasetList', () => { + const mockedProps = {}; + let wrapper; + + beforeAll(async () => { + wrapper = await mountAndWait(mockedProps); + }); it('renders', () => { expect(wrapper.find(DatasetList)).toHaveLength(1); @@ -96,11 +111,63 @@ describe('DatasetList', () => { }); it('fetches data', () => { - // wrapper.update(); const callsD = fetchMock.calls(/dataset\/\?q/); expect(callsD).toHaveLength(1); expect(callsD[0][0]).toMatchInlineSnapshot( `"http://localhost/api/v1/dataset/?q=(order_column:changed_on,order_direction:desc,page:0,page_size:25)"`, ); }); + + it('shows/hides bulk actions when bulk actions is clicked', async () => { + await waitForComponentToPaint(wrapper); + const button = wrapper.find(Button).at(0); + act(() => { + button.props().onClick(); + }); + await waitForComponentToPaint(wrapper); + expect(wrapper.find(IndeterminateCheckbox)).toHaveLength( + mockdatasets.length + 1, // 1 for each row and 1 for select all + ); + }); + + it('renders different bulk selected copy depending on type of row selected', async () => { + // None selected + const checkedEvent = { target: { checked: true } }; + const uncheckedEvent = { target: { checked: false } }; + expect( + wrapper.find('[data-test="bulk-select-copy"]').text(), + ).toMatchInlineSnapshot(`"0 Selected"`); + + // Vitual Selected + act(() => { + wrapper.find(IndeterminateCheckbox).at(1).props().onChange(checkedEvent); + }); + await waitForComponentToPaint(wrapper); + expect( + wrapper.find('[data-test="bulk-select-copy"]').text(), + ).toMatchInlineSnapshot(`"1 Selected (Virtual)"`); + + // Physical Selected + act(() => { + wrapper + .find(IndeterminateCheckbox) + .at(1) + .props() + .onChange(uncheckedEvent); + wrapper.find(IndeterminateCheckbox).at(2).props().onChange(checkedEvent); + }); + await waitForComponentToPaint(wrapper); + expect( + wrapper.find('[data-test="bulk-select-copy"]').text(), + ).toMatchInlineSnapshot(`"1 Selected (Physical)"`); + + // All Selected + act(() => { + wrapper.find(IndeterminateCheckbox).at(0).props().onChange(checkedEvent); + }); + await waitForComponentToPaint(wrapper); + expect( + wrapper.find('[data-test="bulk-select-copy"]').text(), + ).toMatchInlineSnapshot(`"3 Selected (2 Physical, 1 Virtual)"`); + }); }); diff --git a/superset-frontend/src/SqlLab/components/RunQueryActionButton.tsx b/superset-frontend/src/SqlLab/components/RunQueryActionButton.tsx index f1b4c5c027425..84a878235f85e 100644 --- a/superset-frontend/src/SqlLab/components/RunQueryActionButton.tsx +++ b/superset-frontend/src/SqlLab/components/RunQueryActionButton.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { t } from '@superset-ui/translation'; -import Button from '../../components/Button'; +import Button, { ButtonProps } from '../../components/Button'; const NO_OP = () => undefined; @@ -47,7 +47,7 @@ const RunQueryActionButton = ({ const shouldShowStopBtn = !!queryState && ['running', 'pending'].indexOf(queryState) > -1; - const commonBtnProps = { + const commonBtnProps: ButtonProps = { bsSize: 'small', bsStyle: btnStyle, disabled: !dbId, diff --git a/superset-frontend/src/components/Button.jsx b/superset-frontend/src/components/Button.tsx similarity index 50% rename from superset-frontend/src/components/Button.jsx rename to superset-frontend/src/components/Button.tsx index 0be1c6bd8858c..6861fc59f0278 100644 --- a/superset-frontend/src/components/Button.jsx +++ b/superset-frontend/src/components/Button.tsx @@ -17,41 +17,76 @@ * under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import { kebabCase } from 'lodash'; import { Button as BootstrapButton, Tooltip, OverlayTrigger, } from 'react-bootstrap'; +import styled from '@superset-ui/style'; -const propTypes = { - children: PropTypes.node, - className: PropTypes.string, - tooltip: PropTypes.node, - placement: PropTypes.string, - onClick: PropTypes.func, - disabled: PropTypes.bool, - bsSize: PropTypes.string, - bsStyle: PropTypes.string, - btnStyles: PropTypes.string, -}; -const defaultProps = { - bsSize: 'sm', - placement: 'top', -}; +export type OnClickHandler = React.MouseEventHandler; + +export interface ButtonProps { + className?: string; + tooltip?: string; + placement?: string; + onClick?: OnClickHandler; + disabled?: boolean; + bsStyle?: string; + btnStyles?: string; + bsSize?: BootstrapButton.ButtonProps['bsSize']; + style?: BootstrapButton.ButtonProps['style']; + children?: React.ReactNode; +} const BUTTON_WRAPPER_STYLE = { display: 'inline-block', cursor: 'not-allowed' }; -export default function Button(props) { - const buttonProps = { ...props }; +const SupersetButton = styled(BootstrapButton)` + &.supersetButton { + border-radius: ${({ theme }) => theme.borderRadius}px; + border: none; + color: ${({ theme }) => theme.colors.secondary.light5}; + font-size: ${({ theme }) => theme.typography.sizes.s}; + font-weight: ${({ theme }) => theme.typography.weights.bold}; + min-width: ${({ theme }) => theme.gridUnit * 36}px; + min-height: ${({ theme }) => theme.gridUnit * 8}px; + text-transform: uppercase; + margin-left: ${({ theme }) => theme.gridUnit * 4}px; + &:first-of-type { + margin-left: 0; + } + + i { + padding: 0 ${({ theme }) => theme.gridUnit * 2}px 0 0; + } + + &.primary { + background-color: ${({ theme }) => theme.colors.primary.base}; + } + &.secondary { + color: ${({ theme }) => theme.colors.primary.base}; + background-color: ${({ theme }) => theme.colors.primary.light4}; + } + &.danger { + background-color: ${({ theme }) => theme.colors.error.base}; + } + } +`; + +export default function Button(props: ButtonProps) { + const buttonProps = { + ...props, + bsSize: props.bsSize || 'sm', + placement: props.placement || 'top', + }; const tooltip = props.tooltip; const placement = props.placement; delete buttonProps.tooltip; delete buttonProps.placement; let button = ( - {props.children} + {props.children} ); if (tooltip) { if (props.disabled) { @@ -60,7 +95,7 @@ export default function Button(props) { buttonProps.style = { pointerEvents: 'none' }; button = (

- {props.children} + {props.children}
); } @@ -77,6 +112,3 @@ export default function Button(props) { } return button; } - -Button.propTypes = propTypes; -Button.defaultProps = defaultProps; diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx index 6f98c84af9971..dc420cf7781f4 100644 --- a/superset-frontend/src/components/ListView/ListView.tsx +++ b/superset-frontend/src/components/ListView/ListView.tsx @@ -18,7 +18,10 @@ */ import { t } from '@superset-ui/translation'; import React, { FunctionComponent } from 'react'; -import { Col, DropdownButton, MenuItem, Row } from 'react-bootstrap'; +import { Col, Row, Alert } from 'react-bootstrap'; +import styled from '@superset-ui/style'; +import cx from 'classnames'; +import Button from 'src/components/Button'; import Loading from 'src/components/Loading'; import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; import TableCollection from './TableCollection'; @@ -30,7 +33,7 @@ import { ListViewError, useListViewState } from './utils'; import './ListViewStyles.less'; -interface Props { +export interface ListViewProps { columns: any[]; data: any[]; count: number; @@ -42,12 +45,50 @@ interface Props { filters?: Filters; bulkActions?: Array<{ key: string; - name: React.ReactNode | string; + name: React.ReactNode; onSelect: (rows: any[]) => any; + type?: 'primary' | 'secondary' | 'danger'; }>; isSIP34FilterUIEnabled?: boolean; + bulkSelectEnabled?: boolean; + disableBulkSelect?: () => void; + renderBulkSelectCopy?: (selects: any[]) => React.ReactNode; } +const BulkSelectWrapper = styled(Alert)` + border-radius: 0; + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; + padding-right: 36px; + color: #3d3d3d; + background-color: ${({ theme }) => theme.colors.primary.light4}; + + .selectedCopy { + display: inline-block; + padding: 16px 0; + } + + .deselect-all { + color: #1985a0; + margin-left: 16px; + } + + .divider { + margin: -8px 0 -8px 16px; + width: 1px; + height: 32px; + box-shadow: inset -1px 0px 0px #dadada; + display: inline-flex; + vertical-align: middle; + position: relative; + } + + .close { + margin: 16px 0; + } +`; + const bulkSelectColumnConfig = { Cell: ({ row }: any) => ( @@ -62,7 +103,7 @@ const bulkSelectColumnConfig = { size: 'sm', }; -const ListView: FunctionComponent = ({ +const ListView: FunctionComponent = ({ columns, data, count, @@ -74,6 +115,9 @@ const ListView: FunctionComponent = ({ filters = [], bulkActions = [], isSIP34FilterUIEnabled = false, + bulkSelectEnabled = false, + disableBulkSelect = () => {}, + renderBulkSelectCopy = selected => t('%s Selected', selected.length), }) => { const { getTableProps, @@ -90,10 +134,11 @@ const ListView: FunctionComponent = ({ applyFilters, filtersApplied, selectedFlatRows, + toggleAllRowsSelected, state: { pageIndex, pageSize, internalFilters }, } = useListViewState({ bulkSelectColumnConfig, - bulkSelectMode: Boolean(bulkActions.length), + bulkSelectMode: bulkSelectEnabled && Boolean(bulkActions.length), columns, count, data, @@ -155,6 +200,47 @@ const ListView: FunctionComponent = ({ )}
+ {bulkSelectEnabled && ( + +
+ {renderBulkSelectCopy(selectedFlatRows)} +
+ {Boolean(selectedFlatRows.length) && ( + <> + toggleAllRowsSelected(false)} + > + {t('Deselect All')} + +
+ {bulkActions.map(action => ( + + ))} + + )} + + )} = ({
- -
-
- {bulkActions.length > 0 && ( - - {t('Actions')} - - } - > - {bulkActions.map(action => ( - // @ts-ignore - { - action.onSelect( - selectedRows.map((r: any) => r.original), - ); - }} - > - {action.name} - - ))} - - )} -
-
- - showing{' '} diff --git a/superset-frontend/src/components/ListView/ListViewStyles.less b/superset-frontend/src/components/ListView/ListViewStyles.less index 47112265a7f83..f6e3f6fd55e11 100644 --- a/superset-frontend/src/components/ListView/ListViewStyles.less +++ b/superset-frontend/src/components/ListView/ListViewStyles.less @@ -104,8 +104,17 @@ } .table-row { + .actions { + opacity: 0; + } + &:hover { background-color: @brand-secondary-light5; + + .actions { + opacity: 1; + transition: opacity ease-in @timing-normal; + } } } diff --git a/superset-frontend/src/components/ListView/TableCollection.tsx b/superset-frontend/src/components/ListView/TableCollection.tsx index 0efb30b98f423..42bb720cf04a4 100644 --- a/superset-frontend/src/components/ListView/TableCollection.tsx +++ b/superset-frontend/src/components/ListView/TableCollection.tsx @@ -126,13 +126,9 @@ export default function TableCollection({ return ( row.setState && row.setState({ hover: true })} - onMouseLeave={() => - row.setState && row.setState({ hover: false }) - } > {row.cells.map(cell => { if (cell.column.hidden) return null; diff --git a/superset-frontend/src/components/ListView/utils.ts b/superset-frontend/src/components/ListView/utils.ts index c77d05bb4dbfd..f3a697590e088 100644 --- a/superset-frontend/src/components/ListView/utils.ts +++ b/superset-frontend/src/components/ListView/utils.ts @@ -165,6 +165,7 @@ export function useListViewState({ gotoPage, setAllFilters, selectedFlatRows, + toggleAllRowsSelected, state: { pageIndex, pageSize, sortBy, filters }, } = useTable( { @@ -271,6 +272,7 @@ export function useListViewState({ setAllFilters, setInternalFilters, state: { pageIndex, pageSize, sortBy, filters, internalFilters }, + toggleAllRowsSelected, updateInternalFilter, applyFilterValue, }; diff --git a/superset-frontend/src/components/Menu/Menu.jsx b/superset-frontend/src/components/Menu/Menu.jsx index 18e775987fbf9..ccfd94aad2f7f 100644 --- a/superset-frontend/src/components/Menu/Menu.jsx +++ b/superset-frontend/src/components/Menu/Menu.jsx @@ -34,7 +34,8 @@ const propTypes = { path: PropTypes.string.isRequired, icon: PropTypes.string.isRequired, alt: PropTypes.string.isRequired, - width: PropTypes.string.isRequired, + width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + .isRequired, }).isRequired, navbar_right: PropTypes.shape({ bug_report_url: PropTypes.string, diff --git a/superset-frontend/src/components/Menu/SubMenu.tsx b/superset-frontend/src/components/Menu/SubMenu.tsx index 5b45ae11af67f..e34fde210843d 100644 --- a/superset-frontend/src/components/Menu/SubMenu.tsx +++ b/superset-frontend/src/components/Menu/SubMenu.tsx @@ -16,40 +16,30 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useState } from 'react'; +import React from 'react'; import styled from '@superset-ui/style'; -import DatasetModal from 'src/views/datasetList/DatasetModal'; -import { Button, Nav, Navbar, MenuItem } from 'react-bootstrap'; +import { Nav, Navbar, MenuItem } from 'react-bootstrap'; +import Button, { OnClickHandler } from 'src/components/Button'; const StyledHeader = styled.header` margin-top: -20px; .navbar-header .navbar-brand { font-weight: ${({ theme }) => theme.typography.weights.bold}; } - .navbar-right { - .btn-default { - background-color: ${({ theme }) => theme.colors.primary.base}; - border-radius: 4px; - border: none; - color: ${({ theme }) => theme.colors.secondary.light5}; - font-size: ${({ theme }) => theme.typography.sizes.s}; - font-weight: ${({ theme }) => theme.typography.weights.bold}; - margin: 8px 43px; - padding: 8px 51px 8px 43px; - text-transform: uppercase; - i { - padding: 4px ${({ theme }) => theme.typography.sizes.xs}; - } + .supersetButton { + margin: ${({ theme }) => + `${theme.gridUnit * 2}px ${theme.gridUnit * 4}px ${ + theme.gridUnit * 2 + }px 0`}; } } - .navbar-nav { li { a { font-size: ${({ theme }) => theme.typography.sizes.s}; - padding: 8px; - margin: 8px; + padding: ${({ theme }) => theme.gridUnit * 2}px; + margin: ${({ theme }) => theme.gridUnit * 2}px; color: ${({ theme }) => theme.colors.secondary.dark1}; } } @@ -63,70 +53,63 @@ const StyledHeader = styled.header` } `; -interface SubMenuProps { - canCreate?: boolean; - childs?: Array<{ label: string; name: string; url: string }>; - createButton?: { name: string; url: string | null }; - fetchData?: () => void; +type MenuChild = { + label: string; name: string; -} - -const SubMenu = ({ - canCreate, - childs, - createButton, - fetchData, - name, -}: SubMenuProps) => { - const [isModalOpen, setIsModalOpen] = useState(false); - const [selectedMenu, setSelectedMenu] = useState( - childs?.[0]?.label, - ); - - const onOpen = () => { - setIsModalOpen(true); - }; + url: string; +}; - const onClose = () => { - setIsModalOpen(false); +export interface SubMenuProps { + primaryButton?: { + name: React.ReactNode; + onClick: OnClickHandler; }; - - const handleClick = (item: string) => () => { - setSelectedMenu(item); + secondaryButton?: { + name: React.ReactNode; + onClick: OnClickHandler; }; + name: string; + children?: MenuChild[]; + activeChild?: MenuChild['name']; +} +const SubMenu: React.FunctionComponent = props => { return ( - {name} + {props.name} - - {canCreate && createButton && ( - - )} + )} + {props.primaryButton && ( + + )} + ); diff --git a/superset-frontend/src/views/chartList/ChartList.tsx b/superset-frontend/src/views/chartList/ChartList.tsx index 5f96eef2c2665..20b43e218986f 100644 --- a/superset-frontend/src/views/chartList/ChartList.tsx +++ b/superset-frontend/src/views/chartList/ChartList.tsx @@ -26,7 +26,7 @@ import rison from 'rison'; import { Panel } from 'react-bootstrap'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import SubMenu from 'src/components/Menu/SubMenu'; -import ListView from 'src/components/ListView/ListView'; +import ListView, { ListViewProps } from 'src/components/ListView/ListView'; import { FetchDataConfig, FilterOperatorMap, @@ -45,12 +45,13 @@ interface Props { } interface State { - charts: any[]; + bulkSelectEnabled: boolean; chartCount: number; - loading: boolean; + charts: any[]; filterOperators: FilterOperatorMap; filters: Filters; lastFetchDataConfig: FetchDataConfig | null; + loading: boolean; permissions: string[]; // for now we need to use the Slice type defined in PropertiesModal. // In future it would be better to have a unified Chart entity. @@ -63,6 +64,7 @@ class ChartList extends React.PureComponent { }; state: State = { + bulkSelectEnabled: false, chartCount: 0, charts: [], filterOperators: {}, @@ -174,7 +176,7 @@ class ChartList extends React.PureComponent { disableSortBy: true, }, { - Cell: ({ row: { state, original } }: any) => { + Cell: ({ row: { original } }: any) => { const handleDelete = () => this.handleChartDelete(original); const openEditModal = () => this.openChartEditModal(original); if (!this.canEdit && !this.canDelete) { @@ -182,9 +184,7 @@ class ChartList extends React.PureComponent { } return ( - + {this.canDelete && ( { return this.state.permissions.some(p => p === perm); }; + toggleBulkSelect = () => { + this.setState({ bulkSelectEnabled: !this.state.bulkSelectEnabled }); + }; + openChartEditModal = (chart: Chart) => { this.setState({ sliceCurrentlyEditing: { @@ -509,6 +513,7 @@ class ChartList extends React.PureComponent { render() { const { + bulkSelectEnabled, charts, chartCount, loading, @@ -517,7 +522,17 @@ class ChartList extends React.PureComponent { } = this.state; return ( <> - + {sliceCurrentlyEditing && ( { onConfirm={this.handleBulkChartDelete} > {confirmDelete => { - const bulkActions = []; - if (this.canDelete) { - bulkActions.push({ - key: 'delete', - name: ( - <> - {t('Delete')} - - ), - onSelect: confirmDelete, - }); - } + const bulkActions: ListViewProps['bulkActions'] = this.canDelete + ? [ + { + key: 'delete', + name: t('Delete'), + onSelect: confirmDelete, + type: 'danger', + }, + ] + : []; + return ( { initialSort={this.initialSort} filters={filters} bulkActions={bulkActions} + bulkSelectEnabled={bulkSelectEnabled} + disableBulkSelect={this.toggleBulkSelect} isSIP34FilterUIEnabled={this.isSIP34FilterUIEnabled} /> ); diff --git a/superset-frontend/src/views/dashboardList/DashboardList.tsx b/superset-frontend/src/views/dashboardList/DashboardList.tsx index 09c3cc50d43ab..af6cf296be410 100644 --- a/superset-frontend/src/views/dashboardList/DashboardList.tsx +++ b/superset-frontend/src/views/dashboardList/DashboardList.tsx @@ -25,7 +25,7 @@ import rison from 'rison'; import { Panel } from 'react-bootstrap'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import SubMenu from 'src/components/Menu/SubMenu'; -import ListView from 'src/components/ListView/ListView'; +import ListView, { ListViewProps } from 'src/components/ListView/ListView'; import ExpandableList from 'src/components/ExpandableList'; import { FetchDataConfig, @@ -44,23 +44,24 @@ interface Props { } interface State { - dashboards: any[]; + bulkSelectEnabled: boolean; dashboardCount: number; - loading: boolean; + dashboards: any[]; + dashboardToEdit: Dashboard | null; filterOperators: FilterOperatorMap; filters: Filters; - permissions: string[]; lastFetchDataConfig: FetchDataConfig | null; - dashboardToEdit: Dashboard | null; + loading: boolean; + permissions: string[]; } interface Dashboard { - id: number; - changed_by: string; changed_by_name: string; changed_by_url: string; changed_on_delta_humanized: string; + changed_by: string; dashboard_title: string; + id: number; published: boolean; url: string; } @@ -71,14 +72,15 @@ class DashboardList extends React.PureComponent { }; state: State = { + bulkSelectEnabled: false, dashboardCount: 0, dashboards: [], + dashboardToEdit: null, filterOperators: {}, filters: [], lastFetchDataConfig: null, loading: true, permissions: [], - dashboardToEdit: null, }; componentDidMount() { @@ -192,7 +194,7 @@ class DashboardList extends React.PureComponent { disableSortBy: true, }, { - Cell: ({ row: { state, original } }: any) => { + Cell: ({ row: { original } }: any) => { const handleDelete = () => this.handleDashboardDelete(original); const handleEdit = () => this.openDashboardEditModal(original); const handleExport = () => this.handleBulkDashboardExport([original]); @@ -200,9 +202,7 @@ class DashboardList extends React.PureComponent { return null; } return ( - + {this.canDelete && ( { }, ]; + toggleBulkSelect = () => { + this.setState({ bulkSelectEnabled: !this.state.bulkSelectEnabled }); + }; + hasPerm = (perm: string) => { if (!this.state.permissions.length) { return false; @@ -500,15 +504,26 @@ class DashboardList extends React.PureComponent { render() { const { - dashboards, + bulkSelectEnabled, dashboardCount, - loading, - filters, + dashboards, dashboardToEdit, + filters, + loading, } = this.state; return ( <> - + { onConfirm={this.handleBulkDashboardDelete} > {confirmDelete => { - const bulkActions = []; + const bulkActions: ListViewProps['bulkActions'] = []; if (this.canDelete) { bulkActions.push({ key: 'delete', - name: ( - <> - {t('Delete')} - - ), + name: t('Delete'), + type: 'danger', onSelect: confirmDelete, }); } if (this.canExport) { bulkActions.push({ key: 'export', - name: ( - <> - {t('Export')} - - ), + name: t('Export'), + type: 'primary', onSelect: this.handleBulkDashboardExport, }); } @@ -561,6 +570,8 @@ class DashboardList extends React.PureComponent { initialSort={this.initialSort} filters={filters} bulkActions={bulkActions} + bulkSelectEnabled={bulkSelectEnabled} + disableBulkSelect={this.toggleBulkSelect} isSIP34FilterUIEnabled={this.isSIP34FilterUIEnabled} /> diff --git a/superset-frontend/src/views/datasetList/DatasetModal.tsx b/superset-frontend/src/views/datasetList/AddDatasetModal.tsx similarity index 92% rename from superset-frontend/src/views/datasetList/DatasetModal.tsx rename to superset-frontend/src/views/datasetList/AddDatasetModal.tsx index 5dd50f2553430..c13969c3c1104 100644 --- a/superset-frontend/src/views/datasetList/DatasetModal.tsx +++ b/superset-frontend/src/views/datasetList/AddDatasetModal.tsx @@ -26,10 +26,16 @@ import Modal from 'src/components/Modal'; import TableSelector from 'src/components/TableSelector'; import withToasts from '../../messageToasts/enhancers/withToasts'; +type DatasetAddObject = { + id: number; + databse: number; + schema: string; + table_name: string; +}; interface DatasetModalProps { addDangerToast: (msg: string) => void; addSuccessToast: (msg: string) => void; - fetchData?: () => void; + onDatasetAdd?: (dataset: DatasetAddObject) => void; onHide: () => void; show: boolean; } @@ -48,7 +54,7 @@ const TableSelectorContainer = styled.div` const DatasetModal: FunctionComponent = ({ addDangerToast, addSuccessToast, - fetchData, + onDatasetAdd, onHide, show, }) => { @@ -82,9 +88,9 @@ const DatasetModal: FunctionComponent = ({ }), headers: { 'Content-Type': 'application/json' }, }) - .then(() => { - if (fetchData) { - fetchData(); + .then(({ json = {} }) => { + if (onDatasetAdd) { + onDatasetAdd({ id: json.id, ...json.result }); } addSuccessToast(t('The dataset has been saved')); onHide(); diff --git a/superset-frontend/src/views/datasetList/DatasetList.tsx b/superset-frontend/src/views/datasetList/DatasetList.tsx index e6c6248293b1e..a144c2004b076 100644 --- a/superset-frontend/src/views/datasetList/DatasetList.tsx +++ b/superset-frontend/src/views/datasetList/DatasetList.tsx @@ -26,13 +26,11 @@ import React, { useState, } from 'react'; import rison from 'rison'; -// @ts-ignore -import { Panel } from 'react-bootstrap'; import { SHORT_DATE, SHORT_TIME } from 'src/utils/common'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import DeleteModal from 'src/components/DeleteModal'; -import ListView from 'src/components/ListView/ListView'; -import SubMenu from 'src/components/Menu/SubMenu'; +import ListView, { ListViewProps } from 'src/components/ListView/ListView'; +import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu'; import AvatarIcon from 'src/components/AvatarIcon'; import { FetchDataConfig, @@ -42,6 +40,7 @@ import { import withToasts from 'src/messageToasts/enhancers/withToasts'; import TooltipWrapper from 'src/components/TooltipWrapper'; import Icon from 'src/components/Icon'; +import AddDatasetModal from './AddDatasetModal'; const PAGE_SIZE = 25; @@ -52,15 +51,10 @@ type Owner = { username: string; }; -interface DatasetListProps { - addDangerToast: (msg: string) => void; - addSuccessToast: (msg: string) => void; -} - -interface Dataset { - changed_by: string; +type Dataset = { changed_by_name: string; changed_by_url: string; + changed_by: string; changed_on: string; databse_name: string; explore_url: string; @@ -68,6 +62,11 @@ interface Dataset { owners: Array; schema: string; table_name: string; +}; + +interface DatasetListProps { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; } const DatasetList: FunctionComponent = ({ @@ -93,6 +92,11 @@ const DatasetList: FunctionComponent = ({ >([]); const [permissions, setPermissions] = useState([]); + const [datasetAddModalOpen, setDatasetAddModalOpen] = useState( + false, + ); + const [bulkSelectEnabled, setBulkSelectEnabled] = useState(false); + const updateFilters = (filterOperators: FilterOperatorMap) => { const convertFilter = ({ name: label, @@ -187,9 +191,9 @@ const DatasetList: FunctionComponent = ({ return Boolean(permissions.find(p => p === perm)); }; - const canEdit = () => hasPerm('can_edit'); - const canDelete = () => hasPerm('can_delete'); - const canCreate = () => hasPerm('can_add'); + const canEdit = hasPerm('can_edit'); + const canDelete = hasPerm('can_delete'); + const canCreate = hasPerm('can_add'); const initialSort = [{ id: 'changed_on', desc: true }]; @@ -349,16 +353,14 @@ const DatasetList: FunctionComponent = ({ disableSortBy: true, }, { - Cell: ({ row: { state, original } }: any) => { + Cell: ({ row: { original } }: any) => { const handleEdit = () => handleDatasetEdit(original); const handleDelete = () => openDatasetDeleteModal(original); - if (!canEdit() && !canDelete()) { + if (!canEdit && !canDelete) { return null; } return ( - + = ({ )} - {canEdit() && ( + {canEdit && ( = ({ }, ]; - const menu = { + const menuData: SubMenuProps = { + activeChild: 'Datasets', name: t('Data'), - createButton: { - name: t('Dataset'), - url: '/tablemodelview/add', - }, - childs: [ + children: [ { name: 'Datasets', label: t('Datasets'), - url: '/tablemodelview/list/?_flt_1_is_sqllab_view=y', + url: '/tablemodelview/list/', }, { name: 'Databases', label: t('Databases'), url: '/databaseview/list/' }, { @@ -436,6 +435,25 @@ const DatasetList: FunctionComponent = ({ ], }; + if (canCreate) { + menuData.primaryButton = { + name: ( + <> + {' '} + {t('Dataset')}{' '} + + ), + onClick: () => setDatasetAddModalOpen(true), + }; + } + + if (canDelete) { + menuData.secondaryButton = { + name: t('Bulk Select'), + onClick: () => setBulkSelectEnabled(!bulkSelectEnabled), + }; + } + const closeDatasetDeleteModal = () => { setDatasetCurrentlyDeleting(null); }; @@ -519,11 +537,28 @@ const DatasetList: FunctionComponent = ({ return ( <> - lastFetchDataConfig && fetchData(lastFetchDataConfig)} + + setDatasetAddModalOpen(false)} + onDatasetAdd={() => { + if (lastFetchDataConfig) fetchData(lastFetchDataConfig); + }} /> + {datasetCurrentlyDeleting && ( + handleDatasetDelete(datasetCurrentlyDeleting)} + onHide={closeDatasetDeleteModal} + open + title={t('Delete Dataset?')} + /> + )} = ({ onConfirm={handleBulkDatasetDelete} > {confirmDelete => { - const bulkActions = []; - if (canDelete()) { - bulkActions.push({ - key: 'delete', - name: ( - <> - {t('Delete')} - - ), - onSelect: confirmDelete, - }); - } + const bulkActions: ListViewProps['bulkActions'] = canDelete + ? [ + { + key: 'delete', + name: t('Delete'), + onSelect: confirmDelete, + type: 'danger', + }, + ] + : []; + return ( - <> - {datasetCurrentlyDeleting && ( - - handleDatasetDelete(datasetCurrentlyDeleting) - } - onHide={closeDatasetDeleteModal} - open - title={t('Delete Dataset?')} - /> - )} - - + setBulkSelectEnabled(false)} + renderBulkSelectCopy={selected => { + const { virtualCount, physicalCount } = selected.reduce( + (acc, e) => { + if (e.original.kind === 'physical') acc.physicalCount += 1; + else if (e.original.kind === 'virtual') + acc.virtualCount += 1; + return acc; + }, + { virtualCount: 0, physicalCount: 0 }, + ); + + if (!selected.length) { + return t('0 Selected'); + } else if (virtualCount && !physicalCount) { + return t( + '%s Selected (Virtual)', + selected.length, + virtualCount, + ); + } else if (physicalCount && !virtualCount) { + return t( + '%s Selected (Physical)', + selected.length, + physicalCount, + ); + } + + return t( + '%s Selected (%s Physical, %s Virtual)', + selected.length, + physicalCount, + virtualCount, + ); + }} + /> ); }} diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index d6295d3f3ed5b..2bfc2ef38da7c 100644 --- a/superset-frontend/webpack.config.js +++ b/superset-frontend/webpack.config.js @@ -214,6 +214,7 @@ const config = { 'react-dom': '@hot-loader/react-dom', stylesheets: path.resolve(APP_DIR, './stylesheets'), images: path.resolve(APP_DIR, './images'), + spec: path.resolve(APP_DIR, './spec'), }, extensions: ['.ts', '.tsx', '.js', '.jsx'], symlinks: false,