feat(listviews): SIP-34 Bulk Select (#10298)

This commit is contained in:
ʈᵃᵢ 2020-07-16 16:07:49 -07:00 committed by GitHub
parent 2b061fc64b
commit 0eee6785a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 592 additions and 288 deletions

View File

@ -23,6 +23,7 @@ module.exports = {
'\\.(gif|ttf|eot)$': '<rootDir>/spec/__mocks__/fileMock.js',
'\\.svg$': '<rootDir>/spec/__mocks__/svgrMock.js',
'^src/(.*)$': '<rootDir>/src/$1',
'^spec/(.*)$': '<rootDir>/spec/$1',
},
setupFilesAfterEnv: ['<rootDir>/spec/helpers/shim.js'],
testURL: 'http://localhost',

View File

@ -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<P = {}>(
wrapper: ReactWrapper<P>,
amount = 0,
) {
await act(async () => {
await new Promise(resolve => setTimeout(resolve, amount));
wrapper.update();
});
}

View File

@ -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 = {

View File

@ -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(<CodeModal {...mockedProps} />)).toBe(true);
});
it('renders the trigger node', () => {
const wrapper = mount(<CodeModal {...mockedProps} />);
const wrapper = mount(<CodeModal {...mockedProps} />, {
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
});
expect(wrapper.find('.fa-edit')).toHaveLength(1);
});
});

View File

@ -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(<DatasetList {...mockedProps} />, {
async function mountAndWait(props) {
const mounted = mount(<DatasetList {...props} />, {
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)"`);
});
});

View File

@ -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,

View File

@ -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<BootstrapButton>;
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 = (
<BootstrapButton {...buttonProps}>{props.children}</BootstrapButton>
<SupersetButton {...buttonProps}>{props.children}</SupersetButton>
);
if (tooltip) {
if (props.disabled) {
@ -60,7 +95,7 @@ export default function Button(props) {
buttonProps.style = { pointerEvents: 'none' };
button = (
<div style={BUTTON_WRAPPER_STYLE}>
<BootstrapButton {...buttonProps}>{props.children}</BootstrapButton>
<SupersetButton {...buttonProps}>{props.children}</SupersetButton>
</div>
);
}
@ -77,6 +112,3 @@ export default function Button(props) {
}
return button;
}
Button.propTypes = propTypes;
Button.defaultProps = defaultProps;

View File

@ -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) => (
<IndeterminateCheckbox {...row.getToggleRowSelectedProps()} id={row.id} />
@ -62,7 +103,7 @@ const bulkSelectColumnConfig = {
size: 'sm',
};
const ListView: FunctionComponent<Props> = ({
const ListView: FunctionComponent<ListViewProps> = ({
columns,
data,
count,
@ -74,6 +115,9 @@ const ListView: FunctionComponent<Props> = ({
filters = [],
bulkActions = [],
isSIP34FilterUIEnabled = false,
bulkSelectEnabled = false,
disableBulkSelect = () => {},
renderBulkSelectCopy = selected => t('%s Selected', selected.length),
}) => {
const {
getTableProps,
@ -90,10 +134,11 @@ const ListView: FunctionComponent<Props> = ({
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<Props> = ({
)}
</div>
<div className="body">
{bulkSelectEnabled && (
<BulkSelectWrapper
data-test="bulk-select-controls"
bsStyle="info"
onDismiss={disableBulkSelect}
>
<div className="selectedCopy" data-test="bulk-select-copy">
{renderBulkSelectCopy(selectedFlatRows)}
</div>
{Boolean(selectedFlatRows.length) && (
<>
<span
data-test="bulk-select-deselect-all"
role="button"
tabIndex={0}
className="deselect-all"
onClick={() => toggleAllRowsSelected(false)}
>
{t('Deselect All')}
</span>
<div className="divider" />
{bulkActions.map(action => (
<Button
data-test="bulk-select-action"
key={action.key}
className={cx('supersetButton', {
danger: action.type === 'danger',
primary: action.type === 'primary',
secondary: action.type === 'secondary',
})}
onClick={() =>
action.onSelect(selectedFlatRows.map(r => r.original))
}
>
{action.name}
</Button>
))}
</>
)}
</BulkSelectWrapper>
)}
<TableCollection
getTableProps={getTableProps}
getTableBodyProps={getTableBodyProps}
@ -166,42 +252,6 @@ const ListView: FunctionComponent<Props> = ({
</div>
<div className="footer">
<Row>
<Col>
<div className="form-actions-container">
<div className="btn-group">
{bulkActions.length > 0 && (
<DropdownButton
id="bulk-actions"
bsSize="small"
bsStyle="default"
noCaret
title={
<>
{t('Actions')} <span className="caret" />
</>
}
>
{bulkActions.map(action => (
// @ts-ignore
<MenuItem
key={action.key}
eventKey={selectedFlatRows}
// @ts-ignore
onSelect={(selectedRows: typeof selectedFlatRows) => {
action.onSelect(
selectedRows.map((r: any) => r.original),
);
}}
>
{action.name}
</MenuItem>
))}
</DropdownButton>
)}
</div>
</div>
</Col>
<Col>
<span className="row-count-container">
showing{' '}

View File

@ -104,8 +104,17 @@
}
.table-row {
.actions {
opacity: 0;
}
&:hover {
background-color: @brand-secondary-light5;
.actions {
opacity: 1;
transition: opacity ease-in @timing-normal;
}
}
}

View File

@ -126,13 +126,9 @@ export default function TableCollection({
return (
<tr
{...row.getRowProps()}
className={cx({
className={cx('table-row', {
'table-row-selected': row.isSelected,
})}
onMouseEnter={() => row.setState && row.setState({ hover: true })}
onMouseLeave={() =>
row.setState && row.setState({ hover: false })
}
>
{row.cells.map(cell => {
if (cell.column.hidden) return null;

View File

@ -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,
};

View File

@ -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,

View File

@ -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;
url: string;
};
export interface SubMenuProps {
primaryButton?: {
name: React.ReactNode;
onClick: OnClickHandler;
};
secondaryButton?: {
name: React.ReactNode;
onClick: OnClickHandler;
};
name: string;
children?: MenuChild[];
activeChild?: MenuChild['name'];
}
const SubMenu = ({
canCreate,
childs,
createButton,
fetchData,
name,
}: SubMenuProps) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedMenu, setSelectedMenu] = useState<string | undefined>(
childs?.[0]?.label,
);
const onOpen = () => {
setIsModalOpen(true);
};
const onClose = () => {
setIsModalOpen(false);
};
const handleClick = (item: string) => () => {
setSelectedMenu(item);
};
const SubMenu: React.FunctionComponent<SubMenuProps> = props => {
return (
<StyledHeader>
<Navbar inverse fluid role="navigation">
<Navbar.Header>
<Navbar.Brand>{name}</Navbar.Brand>
<Navbar.Brand>{props.name}</Navbar.Brand>
</Navbar.Header>
<DatasetModal
fetchData={fetchData}
onHide={onClose}
show={isModalOpen}
/>
<Nav>
{childs &&
childs.map(child => (
{props.children &&
props.children.map(child => (
<MenuItem
active={child.label === selectedMenu}
eventKey={`${child.name}`}
href={child.url}
active={child.name === props.activeChild}
key={`${child.label}`}
onClick={handleClick(child.label)}
href={child.url}
>
{child.label}
</MenuItem>
))}
</Nav>
{canCreate && createButton && (
<Nav className="navbar-right">
<Button onClick={onOpen}>
<i className="fa fa-plus" /> {createButton.name}
<Nav className="navbar-right">
{props.secondaryButton && (
<Button
className="supersetButton secondary"
onClick={props.secondaryButton.onClick}
>
{props.secondaryButton.name}
</Button>
</Nav>
)}
)}
{props.primaryButton && (
<Button
className="supersetButton primary"
onClick={props.primaryButton.onClick}
>
{props.primaryButton.name}
</Button>
)}
</Nav>
</Navbar>
</StyledHeader>
);

View File

@ -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<Props, State> {
};
state: State = {
bulkSelectEnabled: false,
chartCount: 0,
charts: [],
filterOperators: {},
@ -174,7 +176,7 @@ class ChartList extends React.PureComponent<Props, State> {
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<Props, State> {
}
return (
<span
className={`actions ${state && state.hover ? '' : 'invisible'}`}
>
<span className="actions">
{this.canDelete && (
<ConfirmStatusChange
title={t('Please Confirm')}
@ -235,6 +235,10 @@ class ChartList extends React.PureComponent<Props, State> {
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<Props, State> {
render() {
const {
bulkSelectEnabled,
charts,
chartCount,
loading,
@ -517,7 +522,17 @@ class ChartList extends React.PureComponent<Props, State> {
} = this.state;
return (
<>
<SubMenu name={t('Charts')} />
<SubMenu
name={t('Charts')}
secondaryButton={
this.canDelete
? {
name: t('Bulk Select'),
onClick: this.toggleBulkSelect,
}
: undefined
}
/>
{sliceCurrentlyEditing && (
<PropertiesModal
show
@ -534,18 +549,17 @@ class ChartList extends React.PureComponent<Props, State> {
onConfirm={this.handleBulkChartDelete}
>
{confirmDelete => {
const bulkActions = [];
if (this.canDelete) {
bulkActions.push({
key: 'delete',
name: (
<>
<i className="fa fa-trash" /> {t('Delete')}
</>
),
onSelect: confirmDelete,
});
}
const bulkActions: ListViewProps['bulkActions'] = this.canDelete
? [
{
key: 'delete',
name: t('Delete'),
onSelect: confirmDelete,
type: 'danger',
},
]
: [];
return (
<ListView
className="chart-list-view"
@ -558,6 +572,8 @@ class ChartList extends React.PureComponent<Props, State> {
initialSort={this.initialSort}
filters={filters}
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={this.toggleBulkSelect}
isSIP34FilterUIEnabled={this.isSIP34FilterUIEnabled}
/>
);

View File

@ -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<Props, State> {
};
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<Props, State> {
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<Props, State> {
return null;
}
return (
<span
className={`actions ${state && state.hover ? '' : 'invisible'}`}
>
<span className="actions">
{this.canDelete && (
<ConfirmStatusChange
title={t('Please Confirm')}
@ -255,6 +255,10 @@ class DashboardList extends React.PureComponent<Props, State> {
},
];
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<Props, State> {
render() {
const {
dashboards,
bulkSelectEnabled,
dashboardCount,
loading,
filters,
dashboards,
dashboardToEdit,
filters,
loading,
} = this.state;
return (
<>
<SubMenu name={t('Dashboards')} />
<SubMenu
name={t('Dashboards')}
secondaryButton={
this.canDelete || this.canExport
? {
name: t('Bulk Select'),
onClick: this.toggleBulkSelect,
}
: undefined
}
/>
<ConfirmStatusChange
title={t('Please confirm')}
description={t(
@ -517,26 +532,20 @@ class DashboardList extends React.PureComponent<Props, State> {
onConfirm={this.handleBulkDashboardDelete}
>
{confirmDelete => {
const bulkActions = [];
const bulkActions: ListViewProps['bulkActions'] = [];
if (this.canDelete) {
bulkActions.push({
key: 'delete',
name: (
<>
<i className="fa fa-trash" /> {t('Delete')}
</>
),
name: t('Delete'),
type: 'danger',
onSelect: confirmDelete,
});
}
if (this.canExport) {
bulkActions.push({
key: 'export',
name: (
<>
<i className="fa fa-database" /> {t('Export')}
</>
),
name: t('Export'),
type: 'primary',
onSelect: this.handleBulkDashboardExport,
});
}
@ -561,6 +570,8 @@ class DashboardList extends React.PureComponent<Props, State> {
initialSort={this.initialSort}
filters={filters}
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={this.toggleBulkSelect}
isSIP34FilterUIEnabled={this.isSIP34FilterUIEnabled}
/>
</>

View File

@ -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<DatasetModalProps> = ({
addDangerToast,
addSuccessToast,
fetchData,
onDatasetAdd,
onHide,
show,
}) => {
@ -82,9 +88,9 @@ const DatasetModal: FunctionComponent<DatasetModalProps> = ({
}),
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();

View File

@ -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<Owner>;
schema: string;
table_name: string;
};
interface DatasetListProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
}
const DatasetList: FunctionComponent<DatasetListProps> = ({
@ -93,6 +92,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
>([]);
const [permissions, setPermissions] = useState<string[]>([]);
const [datasetAddModalOpen, setDatasetAddModalOpen] = useState<boolean>(
false,
);
const [bulkSelectEnabled, setBulkSelectEnabled] = useState<boolean>(false);
const updateFilters = (filterOperators: FilterOperatorMap) => {
const convertFilter = ({
name: label,
@ -187,9 +191,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
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<DatasetListProps> = ({
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 (
<span
className={`actions ${state && state.hover ? '' : 'invisible'}`}
>
<span className="actions">
<TooltipWrapper
label="explore-action"
tooltip={t('Explore')}
@ -390,7 +392,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
</TooltipWrapper>
)}
{canEdit() && (
{canEdit && (
<TooltipWrapper
label="edit-action"
tooltip={t('Edit')}
@ -415,17 +417,14 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
},
];
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<DatasetListProps> = ({
],
};
if (canCreate) {
menuData.primaryButton = {
name: (
<>
{' '}
<i className="fa fa-plus" /> {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<DatasetListProps> = ({
return (
<>
<SubMenu
{...menu}
canCreate={canCreate()}
fetchData={() => lastFetchDataConfig && fetchData(lastFetchDataConfig)}
<SubMenu {...menuData} />
<AddDatasetModal
show={datasetAddModalOpen}
onHide={() => setDatasetAddModalOpen(false)}
onDatasetAdd={() => {
if (lastFetchDataConfig) fetchData(lastFetchDataConfig);
}}
/>
{datasetCurrentlyDeleting && (
<DeleteModal
description={t(
'The dataset %s is linked to %s charts that appear on %s dashboards. Are you sure you want to continue? Deleting the dataset will break those objects.',
datasetCurrentlyDeleting.table_name,
datasetCurrentlyDeleting.chart_count,
datasetCurrentlyDeleting.dashboard_count,
)}
onConfirm={() => handleDatasetDelete(datasetCurrentlyDeleting)}
onHide={closeDatasetDeleteModal}
open
title={t('Delete Dataset?')}
/>
)}
<ConfirmStatusChange
title={t('Please confirm')}
description={t(
@ -532,50 +567,66 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
onConfirm={handleBulkDatasetDelete}
>
{confirmDelete => {
const bulkActions = [];
if (canDelete()) {
bulkActions.push({
key: 'delete',
name: (
<>
<i className="fa fa-trash" /> {t('Delete')}
</>
),
onSelect: confirmDelete,
});
}
const bulkActions: ListViewProps['bulkActions'] = canDelete
? [
{
key: 'delete',
name: t('Delete'),
onSelect: confirmDelete,
type: 'danger',
},
]
: [];
return (
<>
{datasetCurrentlyDeleting && (
<DeleteModal
description={t(
`The dataset ${datasetCurrentlyDeleting.table_name} is linked to
${datasetCurrentlyDeleting.chart_count} charts that appear on
${datasetCurrentlyDeleting.dashboard_count} dashboards.
Are you sure you want to continue? Deleting the dataset will break
those objects.`,
)}
onConfirm={() =>
handleDatasetDelete(datasetCurrentlyDeleting)
}
onHide={closeDatasetDeleteModal}
open
title={t('Delete Dataset?')}
/>
)}
<ListView
className="dataset-list-view"
columns={columns}
data={datasets}
count={datasetCount}
pageSize={PAGE_SIZE}
fetchData={fetchData}
loading={loading}
initialSort={initialSort}
filters={currentFilters}
bulkActions={bulkActions}
/>
</>
<ListView
className="dataset-list-view"
columns={columns}
data={datasets}
count={datasetCount}
pageSize={PAGE_SIZE}
fetchData={fetchData}
loading={loading}
initialSort={initialSort}
filters={currentFilters}
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={() => 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,
);
}}
/>
);
}}
</ConfirmStatusChange>

View File

@ -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,