feat(listviews): SIP-34 Bulk Select (#10298)
This commit is contained in:
parent
2b061fc64b
commit
0eee6785a8
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)"`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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{' '}
|
||||
|
|
|
|||
|
|
@ -104,8 +104,17 @@
|
|||
}
|
||||
|
||||
.table-row {
|
||||
.actions {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: @brand-secondary-light5;
|
||||
|
||||
.actions {
|
||||
opacity: 1;
|
||||
transition: opacity ease-in @timing-normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue