mirror of https://github.com/apache/superset.git
feat(listviews): SIP-34 filters for charts, dashboards, datasets (#10335)
This commit is contained in:
parent
4b3d6d1fbd
commit
6f56cd5e9d
|
@ -19,7 +19,6 @@
|
|||
import React from 'react';
|
||||
import { mount, shallow } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MenuItem } from 'react-bootstrap';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import { supersetTheme, ThemeProvider } from '@superset-ui/style';
|
||||
|
||||
|
@ -42,6 +41,7 @@ function makeMockLocation(query) {
|
|||
};
|
||||
}
|
||||
|
||||
const fetchSelectsMock = jest.fn(() => []);
|
||||
const mockedProps = {
|
||||
title: 'Data Table',
|
||||
columns: [
|
||||
|
@ -60,10 +60,26 @@ const mockedProps = {
|
|||
},
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
Header: 'ID',
|
||||
id: 'id',
|
||||
input: 'select',
|
||||
selects: [{ label: 'foo', value: 'bar' }],
|
||||
operator: 'eq',
|
||||
},
|
||||
{
|
||||
Header: 'Name',
|
||||
id: 'name',
|
||||
operators: [{ label: 'Starts With', value: 'sw' }],
|
||||
input: 'search',
|
||||
operator: 'ct',
|
||||
},
|
||||
{
|
||||
Header: 'Age',
|
||||
id: 'age',
|
||||
input: 'select',
|
||||
fetchSelects: fetchSelectsMock,
|
||||
paginate: true,
|
||||
operator: 'eq',
|
||||
},
|
||||
],
|
||||
data: [
|
||||
|
@ -145,59 +161,6 @@ describe('ListView', () => {
|
|||
`);
|
||||
});
|
||||
|
||||
it('calls fetchData on filter', () => {
|
||||
act(() => {
|
||||
wrapper
|
||||
.find('.dropdown-toggle')
|
||||
.children('button')
|
||||
.at(0)
|
||||
.props()
|
||||
.onClick();
|
||||
|
||||
wrapper
|
||||
.find(MenuItem)
|
||||
.at(0)
|
||||
.props()
|
||||
.onSelect({ id: 'name', Header: 'name' });
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
act(() => {
|
||||
wrapper.find('.filter-inputs input[type="text"]').prop('onChange')({
|
||||
persist() {},
|
||||
currentTarget: { value: 'foo' },
|
||||
});
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
act(() => {
|
||||
wrapper.find('[data-test="apply-filters"]').last().prop('onClick')();
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(mockedProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"filters": Array [
|
||||
Object {
|
||||
"id": "name",
|
||||
"operator": "sw",
|
||||
"value": "foo",
|
||||
},
|
||||
],
|
||||
"pageIndex": 0,
|
||||
"pageSize": 1,
|
||||
"sortBy": Array [
|
||||
Object {
|
||||
"desc": false,
|
||||
"id": "id",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('renders pagination controls', () => {
|
||||
expect(wrapper.find(Pagination).exists()).toBe(true);
|
||||
expect(wrapper.find(Pagination.Prev).exists()).toBe(true);
|
||||
|
@ -212,15 +175,9 @@ Array [
|
|||
wrapper.update();
|
||||
|
||||
expect(mockedProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
Object {
|
||||
"filters": Array [
|
||||
Object {
|
||||
"id": "name",
|
||||
"operator": "sw",
|
||||
"value": "foo",
|
||||
},
|
||||
],
|
||||
"filters": Array [],
|
||||
"pageIndex": 1,
|
||||
"pageSize": 1,
|
||||
"sortBy": Array [
|
||||
|
@ -230,8 +187,8 @@ Array [
|
|||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('handles bulk actions on 1 row', () => {
|
||||
|
@ -339,46 +296,6 @@ Array [
|
|||
'"Invalid filter config, some_column is not present in columns"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListView with new UI filters', () => {
|
||||
const fetchSelectsMock = jest.fn(() => []);
|
||||
const newFiltersProps = {
|
||||
...mockedProps,
|
||||
isSIP34FilterUIEnabled: true,
|
||||
filters: [
|
||||
{
|
||||
Header: 'ID',
|
||||
id: 'id',
|
||||
input: 'select',
|
||||
selects: [{ label: 'foo', value: 'bar' }],
|
||||
operator: 'eq',
|
||||
},
|
||||
{
|
||||
Header: 'Name',
|
||||
id: 'name',
|
||||
input: 'search',
|
||||
operator: 'ct',
|
||||
},
|
||||
{
|
||||
Header: 'Age',
|
||||
id: 'age',
|
||||
input: 'select',
|
||||
fetchSelects: fetchSelectsMock,
|
||||
paginate: true,
|
||||
operator: 'eq',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const wrapper = factory(newFiltersProps);
|
||||
|
||||
afterEach(() => {
|
||||
mockedProps.fetchData.mockClear();
|
||||
mockedProps.bulkActions.forEach(ba => {
|
||||
ba.onSelect.mockClear();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders UI filters', () => {
|
||||
expect(wrapper.find(ListViewFilters)).toHaveLength(1);
|
||||
|
@ -407,8 +324,8 @@ describe('ListView with new UI filters', () => {
|
|||
wrapper.find('[data-test="search-input"]').last().props().onBlur();
|
||||
});
|
||||
|
||||
expect(newFiltersProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
expect(mockedProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"filters": Array [
|
||||
Object {
|
||||
|
@ -419,13 +336,18 @@ Array [
|
|||
],
|
||||
"pageIndex": 0,
|
||||
"pageSize": 1,
|
||||
"sortBy": Array [],
|
||||
"sortBy": Array [
|
||||
Object {
|
||||
"desc": false,
|
||||
"id": "id",
|
||||
},
|
||||
]
|
||||
`);
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
|
||||
expect(newFiltersProps.fetchData.mock.calls[1]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
expect(mockedProps.fetchData.mock.calls[1]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"filters": Array [
|
||||
Object {
|
||||
|
@ -441,9 +363,14 @@ Array [
|
|||
],
|
||||
"pageIndex": 0,
|
||||
"pageSize": 1,
|
||||
"sortBy": Array [],
|
||||
"sortBy": Array [
|
||||
Object {
|
||||
"desc": false,
|
||||
"id": "id",
|
||||
},
|
||||
]
|
||||
`);
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -23,7 +23,7 @@ import configureStore from 'redux-mock-store';
|
|||
import fetchMock from 'fetch-mock';
|
||||
import { supersetTheme, ThemeProvider } from '@superset-ui/style';
|
||||
|
||||
import ChartList from 'src/views/chartList/ChartList';
|
||||
import ChartList from 'src/views/CRUD/chart/ChartList';
|
||||
import ListView from 'src/components/ListView/ListView';
|
||||
|
||||
// store needed for withToasts(ChartTable)
|
||||
|
@ -48,13 +48,6 @@ const mockCharts = [...new Array(3)].map((_, i) => ({
|
|||
|
||||
fetchMock.get(chartsInfoEndpoint, {
|
||||
permissions: ['can_list', 'can_edit'],
|
||||
filters: {
|
||||
slice_name: [],
|
||||
description: [],
|
||||
viz_type: [],
|
||||
datasource_name: [],
|
||||
owners: [],
|
||||
},
|
||||
});
|
||||
fetchMock.get(chartssOwnersEndpoint, {
|
||||
result: [],
|
||||
|
@ -95,11 +88,6 @@ describe('ChartList', () => {
|
|||
expect(callsI).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('fetches owners', () => {
|
||||
const callsO = fetchMock.calls(/chart\/related\/owners/);
|
||||
expect(callsO).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('fetches data', () => {
|
||||
wrapper.update();
|
||||
const callsD = fetchMock.calls(/chart\/\?q/);
|
|
@ -23,7 +23,7 @@ import configureStore from 'redux-mock-store';
|
|||
import fetchMock from 'fetch-mock';
|
||||
import { supersetTheme, ThemeProvider } from '@superset-ui/style';
|
||||
|
||||
import DashboardList from 'src/views/dashboardList/DashboardList';
|
||||
import DashboardList from 'src/views/CRUD/dashboard/DashboardList';
|
||||
import ListView from 'src/components/ListView/ListView';
|
||||
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
|
||||
|
||||
|
@ -50,12 +50,6 @@ const mockDashboards = [...new Array(3)].map((_, i) => ({
|
|||
|
||||
fetchMock.get(dashboardsInfoEndpoint, {
|
||||
permissions: ['can_list', 'can_edit'],
|
||||
filters: {
|
||||
dashboard_title: [],
|
||||
slug: [],
|
||||
owners: [],
|
||||
published: [],
|
||||
},
|
||||
});
|
||||
fetchMock.get(dashboardOwnersEndpoint, {
|
||||
result: [],
|
||||
|
@ -86,11 +80,6 @@ describe('DashboardList', () => {
|
|||
expect(callsI).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('fetches owners', () => {
|
||||
const callsO = fetchMock.calls(/dashboard\/related\/owners/);
|
||||
expect(callsO).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('fetches data', () => {
|
||||
wrapper.update();
|
||||
const callsD = fetchMock.calls(/dashboard\/\?q/);
|
|
@ -23,7 +23,7 @@ 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 DatasetList from 'src/views/CRUD/dataset/DatasetList';
|
||||
import ListView from 'src/components/ListView/ListView';
|
||||
import Button from 'src/components/Button';
|
||||
import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
|
||||
|
@ -54,13 +54,6 @@ const mockdatasets = [...new Array(3)].map((_, i) => ({
|
|||
|
||||
fetchMock.get(datasetsInfoEndpoint, {
|
||||
permissions: ['can_list', 'can_edit', 'can_add', 'can_delete'],
|
||||
filters: {
|
||||
database: [],
|
||||
schema: [],
|
||||
table_name: [],
|
||||
owners: [],
|
||||
is_sqllab_view: [],
|
||||
},
|
||||
});
|
||||
fetchMock.get(datasetsOwnersEndpoint, {
|
||||
result: [],
|
||||
|
@ -105,11 +98,6 @@ describe('DatasetList', () => {
|
|||
expect(callsI).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('fetches owners', () => {
|
||||
const callsO = fetchMock.calls(/dataset\/related\/owners/);
|
||||
expect(callsO).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('fetches data', () => {
|
||||
const callsD = fetchMock.calls(/dataset\/\?q/);
|
||||
expect(callsD).toHaveLength(1);
|
|
@ -22,8 +22,8 @@ import { styled, supersetTheme } from '@superset-ui/style';
|
|||
import { t, tn } from '@superset-ui/translation';
|
||||
|
||||
import { noOp } from 'src/utils/common';
|
||||
import Button from 'src/views/CRUD/dataset/Button';
|
||||
import Icon from '../Icon';
|
||||
import Button from '../../views/datasetList/Button';
|
||||
import { ErrorMessageComponentProps } from './types';
|
||||
import CopyToClipboard from '../CopyToClipboard';
|
||||
import IssueCode from './IssueCode';
|
||||
|
|
|
@ -1,204 +0,0 @@
|
|||
/**
|
||||
* 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 { t } from '@superset-ui/translation';
|
||||
import React, { Dispatch, SetStateAction } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
DropdownButton,
|
||||
FormControl,
|
||||
MenuItem,
|
||||
Row,
|
||||
} from 'react-bootstrap';
|
||||
import { Select } from 'src/components/Select';
|
||||
import { Filters, InternalFilter, SelectOption } from './types';
|
||||
import { extractInputValue, getDefaultFilterOperator } from './utils';
|
||||
|
||||
const styleWidth100p = { width: '100%' };
|
||||
|
||||
export const FilterMenu = ({
|
||||
filters,
|
||||
internalFilters,
|
||||
setInternalFilters,
|
||||
}: {
|
||||
filters: Filters;
|
||||
internalFilters: InternalFilter[];
|
||||
setInternalFilters: Dispatch<SetStateAction<InternalFilter[]>>;
|
||||
}) => (
|
||||
<div className="filter-dropdown">
|
||||
<DropdownButton
|
||||
id="filter-picker"
|
||||
bsSize="small"
|
||||
bsStyle={'default'}
|
||||
noCaret
|
||||
title={
|
||||
<>
|
||||
<i className="fa fa-filter text-primary" />
|
||||
{' '}
|
||||
{t('Filter')}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{filters
|
||||
.map(({ id, Header }) => ({
|
||||
Header,
|
||||
id,
|
||||
value: undefined,
|
||||
}))
|
||||
.map(ft => (
|
||||
<MenuItem
|
||||
key={ft.id}
|
||||
eventKey={ft}
|
||||
// @ts-ignore
|
||||
onSelect={(fltr: typeof ft) => {
|
||||
setInternalFilters([...internalFilters, fltr]);
|
||||
}}
|
||||
>
|
||||
{ft.Header}
|
||||
</MenuItem>
|
||||
))}
|
||||
</DropdownButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const FilterInputs = ({
|
||||
internalFilters,
|
||||
filters,
|
||||
updateInternalFilter,
|
||||
removeFilterAndApply,
|
||||
filtersApplied,
|
||||
applyFilters,
|
||||
}: {
|
||||
internalFilters: InternalFilter[];
|
||||
filters: Filters;
|
||||
updateInternalFilter: (i: number, f: object) => void;
|
||||
removeFilterAndApply: (i: number) => void;
|
||||
filtersApplied: boolean;
|
||||
applyFilters: () => void;
|
||||
}) => (
|
||||
<>
|
||||
{internalFilters.map((ft, i) => {
|
||||
const filter = filters.find(f => f.id === ft.id);
|
||||
if (!filter) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`could not find filter for ${ft.id}`);
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={`${ft.Header}-${i}`} className="filter-inputs">
|
||||
<Row>
|
||||
<Col className="text-center filter-column" md={2}>
|
||||
<span>{ft.Header}</span>
|
||||
</Col>
|
||||
<Col md={2}>
|
||||
<FormControl
|
||||
componentClass="select"
|
||||
bsSize="small"
|
||||
value={ft.operator}
|
||||
placeholder={filter ? getDefaultFilterOperator(filter) : ''}
|
||||
// @ts-ignore
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateInternalFilter(i, {
|
||||
operator: e.currentTarget.value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(filter.operators || []).map(
|
||||
({ label, value }: SelectOption) => (
|
||||
<option key={label} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</FormControl>
|
||||
</Col>
|
||||
<Col md={1} />
|
||||
<Col md={4}>
|
||||
{filter.input === 'select' && (
|
||||
<Select
|
||||
autoFocus
|
||||
multi
|
||||
searchable
|
||||
name={`filter-${filter.id}-select`}
|
||||
options={filter.selects}
|
||||
placeholder="Select Value"
|
||||
value={ft.value as SelectOption['value'][] | undefined}
|
||||
onChange={(e: SelectOption[] | null) => {
|
||||
updateInternalFilter(i, {
|
||||
operator: ft.operator || getDefaultFilterOperator(filter),
|
||||
value: e ? e.map(s => s.value) : e,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{filter.input !== 'select' && (
|
||||
// @ts-ignore
|
||||
<FormControl
|
||||
type={filter.input ? filter.input : 'text'}
|
||||
bsSize="small"
|
||||
value={String(ft.value || '')}
|
||||
checked={Boolean(ft.value)}
|
||||
// @ts-ignore
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.persist();
|
||||
updateInternalFilter(i, {
|
||||
operator: ft.operator || getDefaultFilterOperator(filter),
|
||||
value: extractInputValue(filter.input, e),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
<Col md={1}>
|
||||
<div
|
||||
className="filter-close"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => removeFilterAndApply(i)}
|
||||
>
|
||||
<i className="fa fa-close text-primary" />
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{internalFilters.length > 0 && (
|
||||
<>
|
||||
<Row>
|
||||
<Col md={11} />
|
||||
<Col md={1}>
|
||||
<Button
|
||||
data-test="apply-filters"
|
||||
disabled={!!filtersApplied}
|
||||
bsStyle="primary"
|
||||
style={styleWidth100p}
|
||||
onClick={applyFilters}
|
||||
bsSize="small"
|
||||
>
|
||||
{t('Apply')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
|
@ -26,7 +26,6 @@ import Loading from 'src/components/Loading';
|
|||
import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
|
||||
import TableCollection from './TableCollection';
|
||||
import Pagination from './Pagination';
|
||||
import { FilterMenu, FilterInputs } from './LegacyFilters';
|
||||
import FilterControls from './Filters';
|
||||
import { FetchDataConfig, Filters, SortColumn } from './types';
|
||||
import { ListViewError, useListViewState } from './utils';
|
||||
|
@ -198,7 +197,6 @@ export interface ListViewProps {
|
|||
onSelect: (rows: any[]) => any;
|
||||
type?: 'primary' | 'secondary' | 'danger';
|
||||
}>;
|
||||
isSIP34FilterUIEnabled?: boolean;
|
||||
bulkSelectEnabled?: boolean;
|
||||
disableBulkSelect?: () => void;
|
||||
renderBulkSelectCopy?: (selects: any[]) => React.ReactNode;
|
||||
|
@ -263,7 +261,6 @@ const ListView: FunctionComponent<ListViewProps> = ({
|
|||
className = '',
|
||||
filters = [],
|
||||
bulkActions = [],
|
||||
isSIP34FilterUIEnabled = false,
|
||||
bulkSelectEnabled = false,
|
||||
disableBulkSelect = () => {},
|
||||
renderBulkSelectCopy = selected => t('%s Selected', selected.length),
|
||||
|
@ -276,12 +273,7 @@ const ListView: FunctionComponent<ListViewProps> = ({
|
|||
prepareRow,
|
||||
pageCount = 1,
|
||||
gotoPage,
|
||||
removeFilterAndApply,
|
||||
setInternalFilters,
|
||||
updateInternalFilter,
|
||||
applyFilterValue,
|
||||
applyFilters,
|
||||
filtersApplied,
|
||||
selectedFlatRows,
|
||||
toggleAllRowsSelected,
|
||||
state: { pageIndex, pageSize, internalFilters },
|
||||
|
@ -294,7 +286,7 @@ const ListView: FunctionComponent<ListViewProps> = ({
|
|||
fetchData,
|
||||
initialPageSize,
|
||||
initialSort,
|
||||
initialFilters: isSIP34FilterUIEnabled ? filters : [],
|
||||
initialFilters: filters,
|
||||
});
|
||||
const filterable = Boolean(filters.length);
|
||||
if (filterable) {
|
||||
|
@ -317,30 +309,7 @@ const ListView: FunctionComponent<ListViewProps> = ({
|
|||
<ListViewStyles>
|
||||
<div className={`superset-list-view ${className}`}>
|
||||
<div className="header">
|
||||
{!isSIP34FilterUIEnabled && filterable && (
|
||||
<>
|
||||
<Row>
|
||||
<Col md={10} />
|
||||
<Col md={2}>
|
||||
<FilterMenu
|
||||
filters={filters}
|
||||
internalFilters={internalFilters}
|
||||
setInternalFilters={setInternalFilters}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<hr />
|
||||
<FilterInputs
|
||||
internalFilters={internalFilters}
|
||||
filters={filters}
|
||||
updateInternalFilter={updateInternalFilter}
|
||||
removeFilterAndApply={removeFilterAndApply}
|
||||
filtersApplied={filtersApplied}
|
||||
applyFilters={applyFilters}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isSIP34FilterUIEnabled && filterable && (
|
||||
{filterable && (
|
||||
<FilterControls
|
||||
filters={filters}
|
||||
internalFilters={internalFilters}
|
||||
|
|
|
@ -32,7 +32,19 @@ export interface Filter {
|
|||
Header: string;
|
||||
id: string;
|
||||
operators?: SelectOption[];
|
||||
operator?: string;
|
||||
operator?:
|
||||
| 'sw'
|
||||
| 'ew'
|
||||
| 'ct'
|
||||
| 'eq'
|
||||
| 'nsw'
|
||||
| 'new'
|
||||
| 'nct'
|
||||
| 'neq'
|
||||
| 'rel_m_m'
|
||||
| 'rel_o_m'
|
||||
| 'title_or_slug'
|
||||
| 'name_or_description';
|
||||
input?: 'text' | 'textarea' | 'select' | 'checkbox' | 'search';
|
||||
unfilteredLabel?: string;
|
||||
selects?: SelectOption[];
|
||||
|
@ -63,19 +75,3 @@ export interface FetchDataConfig {
|
|||
export interface InternalFilter extends FilterValue {
|
||||
Header?: string;
|
||||
}
|
||||
|
||||
export interface FilterOperatorMap {
|
||||
[columnId: string]: Array<{
|
||||
name: string;
|
||||
operator:
|
||||
| 'sw'
|
||||
| 'ew'
|
||||
| 'ct'
|
||||
| 'eq'
|
||||
| 'nsw'
|
||||
| 'new'
|
||||
| 'nct'
|
||||
| 'neq'
|
||||
| 'rel_m_m';
|
||||
}>;
|
||||
}
|
||||
|
|
|
@ -225,18 +225,6 @@ export function useListViewState({
|
|||
}
|
||||
}, [query]);
|
||||
|
||||
const filtersApplied = internalFilters.every(
|
||||
({ id, value, operator }, index) =>
|
||||
id &&
|
||||
filters[index]?.id === id &&
|
||||
filters[index]?.value === value &&
|
||||
// @ts-ignore
|
||||
filters[index]?.operator === operator,
|
||||
);
|
||||
|
||||
const updateInternalFilter = (index: number, update: object) =>
|
||||
setInternalFilters(updateInList(internalFilters, index, update));
|
||||
|
||||
const applyFilterValue = (index: number, value: any) => {
|
||||
// skip redunundant updates
|
||||
if (internalFilters[index].value === value) {
|
||||
|
@ -249,18 +237,9 @@ export function useListViewState({
|
|||
gotoPage(0); // clear pagination on filter
|
||||
};
|
||||
|
||||
const removeFilterAndApply = (index: number) => {
|
||||
const updated = removeFromList(internalFilters, index);
|
||||
setInternalFilters(updated);
|
||||
setAllFilters(convertFilters(updated));
|
||||
};
|
||||
|
||||
return {
|
||||
applyFilters: () => setAllFilters(convertFilters(internalFilters)),
|
||||
removeFilterAndApply,
|
||||
canNextPage,
|
||||
canPreviousPage,
|
||||
filtersApplied,
|
||||
getTableBodyProps,
|
||||
getTableProps,
|
||||
gotoPage,
|
||||
|
@ -270,10 +249,8 @@ export function useListViewState({
|
|||
rows,
|
||||
selectedFlatRows,
|
||||
setAllFilters,
|
||||
setInternalFilters,
|
||||
state: { pageIndex, pageSize, sortBy, filters, internalFilters },
|
||||
toggleAllRowsSelected,
|
||||
updateInternalFilter,
|
||||
applyFilterValue,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ import React from 'react';
|
|||
import styled from '@superset-ui/style';
|
||||
import { Modal as BaseModal } from 'react-bootstrap';
|
||||
import { t } from '@superset-ui/translation';
|
||||
import Button from '../views/datasetList/Button';
|
||||
import Button from 'src/views/CRUD/dataset/Button';
|
||||
|
||||
interface ModalProps {
|
||||
children: React.ReactNode;
|
||||
|
|
|
@ -26,7 +26,6 @@ export enum FeatureFlag {
|
|||
ESTIMATE_QUERY_COST = 'ESTIMATE_QUERY_COST',
|
||||
SHARE_QUERIES_VIA_KV_STORE = 'SHARE_QUERIES_VIA_KV_STORE',
|
||||
SQLLAB_BACKEND_PERSISTENCE = 'SQLLAB_BACKEND_PERSISTENCE',
|
||||
LIST_VIEWS_SIP34_FILTER_UI = 'LIST_VIEWS_SIP34_FILTER_UI',
|
||||
}
|
||||
|
||||
export type FeatureFlagMap = {
|
||||
|
|
|
@ -22,21 +22,15 @@ import { getChartMetadataRegistry } from '@superset-ui/chart';
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import rison from 'rison';
|
||||
// @ts-ignore
|
||||
import { Panel } from 'react-bootstrap';
|
||||
import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils';
|
||||
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
||||
import SubMenu from 'src/components/Menu/SubMenu';
|
||||
import Icon from 'src/components/Icon';
|
||||
import ListView, { ListViewProps } from 'src/components/ListView/ListView';
|
||||
import {
|
||||
FetchDataConfig,
|
||||
FilterOperatorMap,
|
||||
Filters,
|
||||
} from 'src/components/ListView/types';
|
||||
import { FetchDataConfig, Filters } from 'src/components/ListView/types';
|
||||
import withToasts from 'src/messageToasts/enhancers/withToasts';
|
||||
import PropertiesModal, { Slice } from 'src/explore/components/PropertiesModal';
|
||||
import Chart from 'src/types/Chart';
|
||||
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
|
@ -49,8 +43,6 @@ interface State {
|
|||
bulkSelectEnabled: boolean;
|
||||
chartCount: number;
|
||||
charts: any[];
|
||||
filterOperators: FilterOperatorMap;
|
||||
filters: Filters;
|
||||
lastFetchDataConfig: FetchDataConfig | null;
|
||||
loading: boolean;
|
||||
permissions: string[];
|
||||
|
@ -58,7 +50,23 @@ interface State {
|
|||
// In future it would be better to have a unified Chart entity.
|
||||
sliceCurrentlyEditing: Slice | null;
|
||||
}
|
||||
const createFetchDatasets = (
|
||||
handleError: (err: Response) => void,
|
||||
) => async () => {
|
||||
try {
|
||||
const { json = {} } = await SupersetClient.get({
|
||||
endpoint: '/api/v1/chart/datasources',
|
||||
});
|
||||
|
||||
return json?.result?.map((ds: { label: string; value: any }) => ({
|
||||
...ds,
|
||||
value: JSON.stringify(ds.value),
|
||||
}));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
class ChartList extends React.PureComponent<Props, State> {
|
||||
static propTypes = {
|
||||
addDangerToast: PropTypes.func.isRequired,
|
||||
|
@ -68,8 +76,6 @@ class ChartList extends React.PureComponent<Props, State> {
|
|||
bulkSelectEnabled: false,
|
||||
chartCount: 0,
|
||||
charts: [],
|
||||
filterOperators: {},
|
||||
filters: [],
|
||||
lastFetchDataConfig: null,
|
||||
loading: true,
|
||||
permissions: [],
|
||||
|
@ -81,20 +87,15 @@ class ChartList extends React.PureComponent<Props, State> {
|
|||
endpoint: `/api/v1/chart/_info`,
|
||||
}).then(
|
||||
({ json: infoJson = {} }) => {
|
||||
this.setState(
|
||||
{
|
||||
filterOperators: infoJson.filters,
|
||||
this.setState({
|
||||
permissions: infoJson.permissions,
|
||||
});
|
||||
},
|
||||
this.updateFilters,
|
||||
);
|
||||
},
|
||||
e => {
|
||||
createErrorHandler(errMsg =>
|
||||
this.props.addDangerToast(
|
||||
t('An error occurred while fetching charts: %s', e.statusText),
|
||||
);
|
||||
console.error(e);
|
||||
},
|
||||
t('An error occurred while fetching chart info: %s', errMsg),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -106,10 +107,6 @@ class ChartList extends React.PureComponent<Props, State> {
|
|||
return this.hasPerm('can_delete');
|
||||
}
|
||||
|
||||
get isSIP34FilterUIEnabled() {
|
||||
return isFeatureEnabled(FeatureFlag.LIST_VIEWS_SIP34_FILTER_UI);
|
||||
}
|
||||
|
||||
initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
|
||||
|
||||
columns = [
|
||||
|
@ -228,6 +225,63 @@ class ChartList extends React.PureComponent<Props, State> {
|
|||
},
|
||||
];
|
||||
|
||||
filters: Filters = [
|
||||
{
|
||||
Header: t('Owner'),
|
||||
id: 'owners',
|
||||
input: 'select',
|
||||
operator: 'rel_m_m',
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects: createFetchRelated(
|
||||
'chart',
|
||||
'owners',
|
||||
createErrorHandler(errMsg =>
|
||||
this.props.addDangerToast(
|
||||
t(
|
||||
'An error occurred while fetching chart dataset values: %s',
|
||||
errMsg,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
paginate: true,
|
||||
},
|
||||
{
|
||||
Header: t('Viz Type'),
|
||||
id: 'viz_type',
|
||||
input: 'select',
|
||||
operator: 'eq',
|
||||
unfilteredLabel: 'All',
|
||||
selects: getChartMetadataRegistry()
|
||||
.keys()
|
||||
.map(k => ({ label: k, value: k })),
|
||||
},
|
||||
{
|
||||
Header: t('Dataset'),
|
||||
id: 'datasource',
|
||||
input: 'select',
|
||||
operator: 'eq',
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects: createFetchDatasets(
|
||||
createErrorHandler(errMsg =>
|
||||
this.props.addDangerToast(
|
||||
t(
|
||||
'An error occurred while fetching chart dataset values: %s',
|
||||
errMsg,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
paginate: false,
|
||||
},
|
||||
{
|
||||
Header: t('Search'),
|
||||
id: 'slice_name',
|
||||
input: 'search',
|
||||
operator: 'name_or_description',
|
||||
},
|
||||
];
|
||||
|
||||
hasPerm = (perm: string) => {
|
||||
if (!this.state.permissions.length) {
|
||||
return false;
|
||||
|
@ -295,15 +349,11 @@ class ChartList extends React.PureComponent<Props, State> {
|
|||
}
|
||||
this.props.addSuccessToast(json.message);
|
||||
},
|
||||
(err: any) => {
|
||||
console.error(err);
|
||||
createErrorHandler(errMsg =>
|
||||
this.props.addDangerToast(
|
||||
t(
|
||||
'There was an issue deleting the selected charts: %s',
|
||||
err.statusText,
|
||||
t('There was an issue deleting the selected charts: %s', errMsg),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -354,171 +404,27 @@ class ChartList extends React.PureComponent<Props, State> {
|
|||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/chart/?q=${queryParams}`,
|
||||
})
|
||||
.then(({ json = {} }) => {
|
||||
.then(
|
||||
({ json = {} }) => {
|
||||
this.setState({ charts: json.result, chartCount: json.count });
|
||||
})
|
||||
.catch(e => {
|
||||
console.log(e.body);
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
this.props.addDangerToast(
|
||||
t('An error occurred while fetching charts: %s', e.statusText),
|
||||
);
|
||||
})
|
||||
t('An error occurred while fetching charts: %s', errMsg),
|
||||
),
|
||||
),
|
||||
)
|
||||
.finally(() => {
|
||||
this.setState({ loading: false });
|
||||
});
|
||||
};
|
||||
|
||||
fetchOwners = async (
|
||||
filterValue = '',
|
||||
pageIndex?: number,
|
||||
pageSize?: number,
|
||||
) => {
|
||||
const resource = '/api/v1/chart/related/owners';
|
||||
|
||||
try {
|
||||
const queryParams = rison.encode({
|
||||
...(pageIndex ? { page: pageIndex } : {}),
|
||||
...(pageSize ? { page_ize: pageSize } : {}),
|
||||
...(filterValue ? { filter: filterValue } : {}),
|
||||
});
|
||||
const { json = {} } = await SupersetClient.get({
|
||||
endpoint: `${resource}?q=${queryParams}`,
|
||||
});
|
||||
|
||||
return json?.result?.map(
|
||||
({ text: label, value }: { text: string; value: any }) => ({
|
||||
label,
|
||||
value,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.props.addDangerToast(
|
||||
t(
|
||||
'An error occurred while fetching chart owner values: %s',
|
||||
e.statusText,
|
||||
),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
fetchDatasets = async () => {
|
||||
const resource = '/api/v1/chart/datasources';
|
||||
try {
|
||||
const { json = {} } = await SupersetClient.get({
|
||||
endpoint: `${resource}`,
|
||||
});
|
||||
|
||||
return json?.result?.map((ds: { label: string; value: any }) => ({
|
||||
...ds,
|
||||
value: JSON.stringify(ds.value),
|
||||
}));
|
||||
} catch (e) {
|
||||
this.props.addDangerToast(
|
||||
t(
|
||||
'An error occurred while fetching chart dataset values: %s',
|
||||
e.statusText,
|
||||
),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
updateFilters = async () => {
|
||||
const { filterOperators } = this.state;
|
||||
|
||||
if (this.isSIP34FilterUIEnabled) {
|
||||
this.setState({
|
||||
filters: [
|
||||
{
|
||||
Header: 'Owner',
|
||||
id: 'owners',
|
||||
input: 'select',
|
||||
operator: 'rel_m_m',
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects: this.fetchOwners,
|
||||
paginate: true,
|
||||
},
|
||||
{
|
||||
Header: 'Viz Type',
|
||||
id: 'viz_type',
|
||||
input: 'select',
|
||||
operator: 'eq',
|
||||
unfilteredLabel: 'All',
|
||||
selects: getChartMetadataRegistry()
|
||||
.keys()
|
||||
.map(k => ({ label: k, value: k })),
|
||||
},
|
||||
{
|
||||
Header: 'Dataset',
|
||||
id: 'datasource',
|
||||
input: 'select',
|
||||
operator: 'eq',
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects: this.fetchDatasets,
|
||||
paginate: false,
|
||||
},
|
||||
{
|
||||
Header: 'Search',
|
||||
id: 'slice_name',
|
||||
input: 'search',
|
||||
operator: 'name_or_description',
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const convertFilter = ({
|
||||
name: label,
|
||||
operator,
|
||||
}: {
|
||||
name: string;
|
||||
operator: string;
|
||||
}) => ({ label, value: operator });
|
||||
|
||||
const owners = await this.fetchOwners();
|
||||
this.setState({
|
||||
filters: [
|
||||
{
|
||||
Header: 'Chart',
|
||||
id: 'slice_name',
|
||||
operators: filterOperators.slice_name.map(convertFilter),
|
||||
},
|
||||
{
|
||||
Header: 'Description',
|
||||
id: 'description',
|
||||
operators: filterOperators.slice_name.map(convertFilter),
|
||||
},
|
||||
{
|
||||
Header: 'Visualization Type',
|
||||
id: 'viz_type',
|
||||
operators: filterOperators.viz_type.map(convertFilter),
|
||||
},
|
||||
{
|
||||
Header: 'Datasource Name',
|
||||
id: 'datasource_name',
|
||||
operators: filterOperators.datasource_name.map(convertFilter),
|
||||
},
|
||||
{
|
||||
Header: 'Owners',
|
||||
id: 'owners',
|
||||
input: 'select',
|
||||
operators: filterOperators.owners.map(convertFilter),
|
||||
selects: owners,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
bulkSelectEnabled,
|
||||
charts,
|
||||
chartCount,
|
||||
loading,
|
||||
filters,
|
||||
sliceCurrentlyEditing,
|
||||
} = this.state;
|
||||
return (
|
||||
|
@ -536,9 +442,9 @@ class ChartList extends React.PureComponent<Props, State> {
|
|||
/>
|
||||
{sliceCurrentlyEditing && (
|
||||
<PropertiesModal
|
||||
show
|
||||
onHide={this.closeChartEditModal}
|
||||
onSave={this.handleChartUpdated}
|
||||
show
|
||||
slice={sliceCurrentlyEditing}
|
||||
/>
|
||||
)}
|
||||
|
@ -563,19 +469,18 @@ class ChartList extends React.PureComponent<Props, State> {
|
|||
|
||||
return (
|
||||
<ListView
|
||||
className="chart-list-view"
|
||||
columns={this.columns}
|
||||
data={charts}
|
||||
count={chartCount}
|
||||
pageSize={PAGE_SIZE}
|
||||
fetchData={this.fetchData}
|
||||
loading={loading}
|
||||
initialSort={this.initialSort}
|
||||
filters={filters}
|
||||
bulkActions={bulkActions}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
className="chart-list-view"
|
||||
columns={this.columns}
|
||||
count={chartCount}
|
||||
data={charts}
|
||||
disableBulkSelect={this.toggleBulkSelect}
|
||||
isSIP34FilterUIEnabled={this.isSIP34FilterUIEnabled}
|
||||
fetchData={this.fetchData}
|
||||
filters={this.filters}
|
||||
initialSort={this.initialSort}
|
||||
loading={loading}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
);
|
||||
}}
|
|
@ -21,21 +21,15 @@ import { t } from '@superset-ui/translation';
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import rison from 'rison';
|
||||
// @ts-ignore
|
||||
import { Panel } from 'react-bootstrap';
|
||||
import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils';
|
||||
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
||||
import SubMenu from 'src/components/Menu/SubMenu';
|
||||
import ListView, { ListViewProps } from 'src/components/ListView/ListView';
|
||||
import ExpandableList from 'src/components/ExpandableList';
|
||||
import {
|
||||
FetchDataConfig,
|
||||
FilterOperatorMap,
|
||||
Filters,
|
||||
} from 'src/components/ListView/types';
|
||||
import { FetchDataConfig, Filters } from 'src/components/ListView/types';
|
||||
import withToasts from 'src/messageToasts/enhancers/withToasts';
|
||||
import Icon from 'src/components/Icon';
|
||||
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
|
||||
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
|
@ -49,8 +43,6 @@ interface State {
|
|||
dashboardCount: number;
|
||||
dashboards: any[];
|
||||
dashboardToEdit: Dashboard | null;
|
||||
filterOperators: FilterOperatorMap;
|
||||
filters: Filters;
|
||||
lastFetchDataConfig: FetchDataConfig | null;
|
||||
loading: boolean;
|
||||
permissions: string[];
|
||||
|
@ -77,8 +69,6 @@ class DashboardList extends React.PureComponent<Props, State> {
|
|||
dashboardCount: 0,
|
||||
dashboards: [],
|
||||
dashboardToEdit: null,
|
||||
filterOperators: {},
|
||||
filters: [],
|
||||
lastFetchDataConfig: null,
|
||||
loading: true,
|
||||
permissions: [],
|
||||
|
@ -89,23 +79,15 @@ class DashboardList extends React.PureComponent<Props, State> {
|
|||
endpoint: `/api/v1/dashboard/_info`,
|
||||
}).then(
|
||||
({ json: infoJson = {} }) => {
|
||||
this.setState(
|
||||
{
|
||||
filterOperators: infoJson.filters,
|
||||
this.setState({
|
||||
permissions: infoJson.permissions,
|
||||
});
|
||||
},
|
||||
this.updateFilters,
|
||||
);
|
||||
},
|
||||
e => {
|
||||
createErrorHandler(errMsg =>
|
||||
this.props.addDangerToast(
|
||||
t(
|
||||
'An error occurred while fetching Dashboards: %s, %s',
|
||||
e.statusText,
|
||||
t('An error occurred while fetching Dashboards: %s, %s', errMsg),
|
||||
),
|
||||
),
|
||||
);
|
||||
console.error(e);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -121,10 +103,6 @@ class DashboardList extends React.PureComponent<Props, State> {
|
|||
return this.hasPerm('can_mulexport');
|
||||
}
|
||||
|
||||
get isSIP34FilterUIEnabled() {
|
||||
return isFeatureEnabled(FeatureFlag.LIST_VIEWS_SIP34_FILTER_UI);
|
||||
}
|
||||
|
||||
initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
|
||||
|
||||
columns = [
|
||||
|
@ -260,6 +238,46 @@ class DashboardList extends React.PureComponent<Props, State> {
|
|||
this.setState({ bulkSelectEnabled: !this.state.bulkSelectEnabled });
|
||||
};
|
||||
|
||||
filters: Filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
id: 'owners',
|
||||
input: 'select',
|
||||
operator: 'rel_m_m',
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects: createFetchRelated(
|
||||
'dashboard',
|
||||
'owners',
|
||||
createErrorHandler(errMsg =>
|
||||
this.props.addDangerToast(
|
||||
t(
|
||||
'An error occurred while fetching chart owner values: %s',
|
||||
errMsg,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
paginate: true,
|
||||
},
|
||||
{
|
||||
Header: 'Published',
|
||||
id: 'published',
|
||||
input: 'select',
|
||||
operator: 'eq',
|
||||
unfilteredLabel: 'Any',
|
||||
selects: [
|
||||
{ label: 'Published', value: true },
|
||||
{ label: 'Unpublished', value: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
Header: 'Search',
|
||||
id: 'dashboard_title',
|
||||
input: 'search',
|
||||
operator: 'title_or_slug',
|
||||
},
|
||||
];
|
||||
|
||||
hasPerm = (perm: string) => {
|
||||
if (!this.state.permissions.length) {
|
||||
return false;
|
||||
|
@ -278,8 +296,8 @@ class DashboardList extends React.PureComponent<Props, State> {
|
|||
this.setState({ loading: true });
|
||||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/${edits.id}`,
|
||||
})
|
||||
.then(({ json = {} }) => {
|
||||
}).then(
|
||||
({ json = {} }) => {
|
||||
this.setState({
|
||||
dashboards: this.state.dashboards.map(dashboard => {
|
||||
if (dashboard.id === json.id) {
|
||||
|
@ -289,12 +307,13 @@ class DashboardList extends React.PureComponent<Props, State> {
|
|||
}),
|
||||
loading: false,
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
this.props.addDangerToast(
|
||||
t('An error occurred while fetching dashboards: %s', e.statusText),
|
||||
t('An error occurred while fetching dashboards: %s', errMsg),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
handleDashboardDelete = ({
|
||||
|
@ -311,12 +330,11 @@ class DashboardList extends React.PureComponent<Props, State> {
|
|||
}
|
||||
this.props.addSuccessToast(t('Deleted: %s', dashboardTitle));
|
||||
},
|
||||
(err: any) => {
|
||||
console.error(err);
|
||||
createErrorHandler(errMsg =>
|
||||
this.props.addDangerToast(
|
||||
t('There was an issue deleting %s', dashboardTitle),
|
||||
);
|
||||
},
|
||||
t('There was an issue deleting %s: %s', dashboardTitle, errMsg),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
handleBulkDashboardDelete = (dashboards: Dashboard[]) => {
|
||||
|
@ -332,15 +350,11 @@ class DashboardList extends React.PureComponent<Props, State> {
|
|||
}
|
||||
this.props.addSuccessToast(json.message);
|
||||
},
|
||||
(err: any) => {
|
||||
console.error(err);
|
||||
createErrorHandler(errMsg =>
|
||||
this.props.addDangerToast(
|
||||
t(
|
||||
'There was an issue deleting the selected dashboards: ',
|
||||
err.statusText,
|
||||
t('There was an issue deleting the selected dashboards: ', errMsg),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -380,137 +394,31 @@ class DashboardList extends React.PureComponent<Props, State> {
|
|||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/?q=${queryParams}`,
|
||||
})
|
||||
.then(({ json = {} }) => {
|
||||
this.setState({ dashboards: json.result, dashboardCount: json.count });
|
||||
})
|
||||
.catch(e => {
|
||||
.then(
|
||||
({ json = {} }) => {
|
||||
this.setState({
|
||||
dashboards: json.result,
|
||||
dashboardCount: json.count,
|
||||
});
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
this.props.addDangerToast(
|
||||
t('An error occurred while fetching dashboards: %s', e.statusText),
|
||||
);
|
||||
})
|
||||
t('An error occurred while fetching dashboards: %s', errMsg),
|
||||
),
|
||||
),
|
||||
)
|
||||
.finally(() => {
|
||||
this.setState({ loading: false });
|
||||
});
|
||||
};
|
||||
|
||||
fetchOwners = async (
|
||||
filterValue = '',
|
||||
pageIndex?: number,
|
||||
pageSize?: number,
|
||||
) => {
|
||||
const resource = '/api/v1/dashboard/related/owners';
|
||||
|
||||
try {
|
||||
const queryParams = rison.encode({
|
||||
...(pageIndex ? { page: pageIndex } : {}),
|
||||
...(pageSize ? { page_ize: pageSize } : {}),
|
||||
...(filterValue ? { filter: filterValue } : {}),
|
||||
});
|
||||
const { json = {} } = await SupersetClient.get({
|
||||
endpoint: `${resource}?q=${queryParams}`,
|
||||
});
|
||||
|
||||
return json?.result?.map(
|
||||
({ text: label, value }: { text: string; value: any }) => ({
|
||||
label,
|
||||
value,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.props.addDangerToast(
|
||||
t(
|
||||
'An error occurred while fetching chart owner values: %s',
|
||||
e.statusText,
|
||||
),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
updateFilters = async () => {
|
||||
const { filterOperators } = this.state;
|
||||
|
||||
if (this.isSIP34FilterUIEnabled) {
|
||||
return this.setState({
|
||||
filters: [
|
||||
{
|
||||
Header: 'Owner',
|
||||
id: 'owners',
|
||||
input: 'select',
|
||||
operator: 'rel_m_m',
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects: this.fetchOwners,
|
||||
paginate: true,
|
||||
},
|
||||
{
|
||||
Header: 'Published',
|
||||
id: 'published',
|
||||
input: 'select',
|
||||
operator: 'eq',
|
||||
unfilteredLabel: 'Any',
|
||||
selects: [
|
||||
{ label: 'Published', value: true },
|
||||
{ label: 'Unpublished', value: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
Header: 'Search',
|
||||
id: 'dashboard_title',
|
||||
input: 'search',
|
||||
operator: 'title_or_slug',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const convertFilter = ({
|
||||
name: label,
|
||||
operator,
|
||||
}: {
|
||||
name: string;
|
||||
operator: string;
|
||||
}) => ({ label, value: operator });
|
||||
|
||||
const owners = await this.fetchOwners();
|
||||
|
||||
return this.setState({
|
||||
filters: [
|
||||
{
|
||||
Header: 'Dashboard',
|
||||
id: 'dashboard_title',
|
||||
operators: filterOperators.dashboard_title.map(convertFilter),
|
||||
},
|
||||
{
|
||||
Header: 'Slug',
|
||||
id: 'slug',
|
||||
operators: filterOperators.slug.map(convertFilter),
|
||||
},
|
||||
{
|
||||
Header: 'Owners',
|
||||
id: 'owners',
|
||||
input: 'select',
|
||||
operators: filterOperators.owners.map(convertFilter),
|
||||
selects: owners,
|
||||
},
|
||||
{
|
||||
Header: 'Published',
|
||||
id: 'published',
|
||||
input: 'checkbox',
|
||||
operators: filterOperators.published.map(convertFilter),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
bulkSelectEnabled,
|
||||
dashboardCount,
|
||||
dashboards,
|
||||
dashboardToEdit,
|
||||
filters,
|
||||
dashboardCount,
|
||||
loading,
|
||||
dashboardToEdit,
|
||||
} = this.state;
|
||||
return (
|
||||
<>
|
||||
|
@ -554,26 +462,25 @@ class DashboardList extends React.PureComponent<Props, State> {
|
|||
<>
|
||||
{dashboardToEdit && (
|
||||
<PropertiesModal
|
||||
show
|
||||
dashboardId={dashboardToEdit.id}
|
||||
onHide={() => this.setState({ dashboardToEdit: null })}
|
||||
onDashboardSave={this.handleDashboardEdit}
|
||||
onHide={() => this.setState({ dashboardToEdit: null })}
|
||||
show
|
||||
/>
|
||||
)}
|
||||
<ListView
|
||||
className="dashboard-list-view"
|
||||
columns={this.columns}
|
||||
data={dashboards}
|
||||
count={dashboardCount}
|
||||
pageSize={PAGE_SIZE}
|
||||
fetchData={this.fetchData}
|
||||
loading={loading}
|
||||
initialSort={this.initialSort}
|
||||
filters={filters}
|
||||
bulkActions={bulkActions}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
className="dashboard-list-view"
|
||||
columns={this.columns}
|
||||
count={dashboardCount}
|
||||
data={dashboards}
|
||||
disableBulkSelect={this.toggleBulkSelect}
|
||||
isSIP34FilterUIEnabled={this.isSIP34FilterUIEnabled}
|
||||
fetchData={this.fetchData}
|
||||
filters={this.filters}
|
||||
initialSort={this.initialSort}
|
||||
loading={loading}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
</>
|
||||
);
|
|
@ -24,7 +24,8 @@ import { t } from '@superset-ui/translation';
|
|||
import Icon from 'src/components/Icon';
|
||||
import Modal from 'src/components/Modal';
|
||||
import TableSelector from 'src/components/TableSelector';
|
||||
import withToasts from '../../messageToasts/enhancers/withToasts';
|
||||
import withToasts from 'src/messageToasts/enhancers/withToasts';
|
||||
import { createErrorHandler } from 'src/views/CRUD/utils';
|
||||
|
||||
type DatasetAddObject = {
|
||||
id: number;
|
||||
|
@ -95,10 +96,11 @@ const DatasetModal: FunctionComponent<DatasetModalProps> = ({
|
|||
addSuccessToast(t('The dataset has been saved'));
|
||||
onHide();
|
||||
})
|
||||
.catch(e => {
|
||||
addDangerToast(t('Error while saving dataset'));
|
||||
console.error(e);
|
||||
});
|
||||
.catch(
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(t('Error while saving dataset: %s', errMsg)),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
|
@ -25,16 +25,13 @@ import React, {
|
|||
useState,
|
||||
} from 'react';
|
||||
import rison from 'rison';
|
||||
import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils';
|
||||
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
||||
import DeleteModal from 'src/components/DeleteModal';
|
||||
import ListView, { ListViewProps } from 'src/components/ListView/ListView';
|
||||
import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
|
||||
import AvatarIcon from 'src/components/AvatarIcon';
|
||||
import {
|
||||
FetchDataConfig,
|
||||
FilterOperatorMap,
|
||||
Filters,
|
||||
} from 'src/components/ListView/types';
|
||||
import { FetchDataConfig, Filters } from 'src/components/ListView/types';
|
||||
import withToasts from 'src/messageToasts/enhancers/withToasts';
|
||||
import TooltipWrapper from 'src/components/TooltipWrapper';
|
||||
import Icon from 'src/components/Icon';
|
||||
|
@ -69,29 +66,92 @@ interface DatasetListProps {
|
|||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
}
|
||||
interface Database {
|
||||
allow_csv_upload: boolean;
|
||||
allow_ctas: boolean;
|
||||
allow_cvas: null | boolean;
|
||||
allow_dml: boolean;
|
||||
allow_multi_schema_metadata_fetch: boolean;
|
||||
allow_run_async: boolean;
|
||||
allows_cost_estimate: boolean;
|
||||
allows_subquery: boolean;
|
||||
allows_virtual_table_explore: boolean;
|
||||
backend: string;
|
||||
database_name: string;
|
||||
explore_database_id: number;
|
||||
expose_in_sqllab: boolean;
|
||||
force_ctas_schema: null | boolean;
|
||||
function_names: string[];
|
||||
id: number;
|
||||
}
|
||||
|
||||
const createFetchDatabases = (handleError: (err: Response) => void) => async (
|
||||
filterValue = '',
|
||||
pageIndex?: number,
|
||||
pageSize?: number,
|
||||
) => {
|
||||
try {
|
||||
const queryParams = rison.encode({
|
||||
columns: ['database_name', 'id'],
|
||||
keys: ['none'],
|
||||
...(pageIndex ? { page: pageIndex } : {}),
|
||||
...(pageSize ? { page_size: pageSize } : {}),
|
||||
...(filterValue ? { filter: filterValue } : {}),
|
||||
});
|
||||
const { json = {} } = await SupersetClient.get({
|
||||
endpoint: `/api/v1/database/?q=${queryParams}`,
|
||||
});
|
||||
|
||||
return json?.result?.map(
|
||||
({ database_name: label, id: value }: Database) => ({
|
||||
label,
|
||||
value,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const createFetchSchemas = (
|
||||
handleError: (error: Response) => void,
|
||||
) => async (filterValue = '', pageIndex?: number, pageSize?: number) => {
|
||||
try {
|
||||
const queryParams = rison.encode({
|
||||
...(pageIndex ? { page: pageIndex } : {}),
|
||||
...(pageSize ? { page_size: pageSize } : {}),
|
||||
...(filterValue ? { filter: filterValue } : {}),
|
||||
});
|
||||
const { json = {} } = await SupersetClient.get({
|
||||
endpoint: `/api/v1/database/schemas/?q=${queryParams}`,
|
||||
});
|
||||
|
||||
return json?.result?.map(
|
||||
({ text: label, value }: { text: string; value: any }) => ({
|
||||
label,
|
||||
value,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
}) => {
|
||||
const [databases, setDatabases] = useState<{ text: string; value: number }[]>(
|
||||
[],
|
||||
);
|
||||
const [datasetCount, setDatasetCount] = useState(0);
|
||||
const [datasetCurrentlyDeleting, setDatasetCurrentlyDeleting] = useState<
|
||||
(Dataset & { chart_count: number; dashboard_count: number }) | null
|
||||
>(null);
|
||||
const [datasets, setDatasets] = useState<any[]>([]);
|
||||
const [currentFilters, setCurrentFilters] = useState<Filters>([]);
|
||||
const [filterOperators, setFilterOperators] = useState<FilterOperatorMap>();
|
||||
const [
|
||||
lastFetchDataConfig,
|
||||
setLastFetchDataConfig,
|
||||
] = useState<FetchDataConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentOwners, setCurrentOwners] = useState<
|
||||
{ text: string; value: number }[]
|
||||
>([]);
|
||||
const [permissions, setPermissions] = useState<string[]>([]);
|
||||
|
||||
const [datasetAddModalOpen, setDatasetAddModalOpen] = useState<boolean>(
|
||||
|
@ -99,98 +159,85 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
|||
);
|
||||
const [bulkSelectEnabled, setBulkSelectEnabled] = useState<boolean>(false);
|
||||
|
||||
const updateFilters = () => {
|
||||
const convertFilter = ({
|
||||
name: label,
|
||||
operator,
|
||||
}: {
|
||||
name: string;
|
||||
operator: string;
|
||||
}) => ({ label, value: operator });
|
||||
if (filterOperators) {
|
||||
setCurrentFilters([
|
||||
const filterTypes: Filters = [
|
||||
{
|
||||
Header: 'Database',
|
||||
id: 'database',
|
||||
input: 'select',
|
||||
operators: filterOperators.database.map(convertFilter),
|
||||
selects: databases.map(({ text: label, value }) => ({
|
||||
label,
|
||||
value,
|
||||
})),
|
||||
},
|
||||
{
|
||||
Header: 'Schema',
|
||||
id: 'schema',
|
||||
operators: filterOperators.schema.map(convertFilter),
|
||||
},
|
||||
{
|
||||
Header: 'Table Name',
|
||||
id: 'table_name',
|
||||
operators: filterOperators.table_name.map(convertFilter),
|
||||
},
|
||||
{
|
||||
Header: 'Owners',
|
||||
Header: t('Owner'),
|
||||
id: 'owners',
|
||||
input: 'select',
|
||||
operators: filterOperators.owners.map(convertFilter),
|
||||
selects: currentOwners.map(({ text: label, value }) => ({
|
||||
label,
|
||||
value,
|
||||
})),
|
||||
operator: 'rel_m_m',
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects: createFetchRelated(
|
||||
'dataset',
|
||||
'owners',
|
||||
createErrorHandler(errMsg =>
|
||||
t(
|
||||
'An error occurred while fetching dataset owner values: %s',
|
||||
errMsg,
|
||||
),
|
||||
),
|
||||
),
|
||||
paginate: true,
|
||||
},
|
||||
{
|
||||
Header: 'SQL Lab View',
|
||||
id: 'is_sqllab_view',
|
||||
input: 'checkbox',
|
||||
operators: filterOperators.is_sqllab_view.map(convertFilter),
|
||||
Header: t('Datasource'),
|
||||
id: 'database',
|
||||
input: 'select',
|
||||
operator: 'rel_o_m',
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects: createFetchDatabases(
|
||||
createErrorHandler(errMsg =>
|
||||
t('An error occurred while fetching datasource values: %s', errMsg),
|
||||
),
|
||||
),
|
||||
paginate: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
{
|
||||
Header: t('Schema'),
|
||||
id: 'schema',
|
||||
input: 'select',
|
||||
operator: 'eq',
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects: createFetchSchemas(errMsg =>
|
||||
t('An error occurred while fetching schema values: %s', errMsg),
|
||||
),
|
||||
paginate: true,
|
||||
},
|
||||
{
|
||||
Header: t('Type'),
|
||||
id: 'is_sqllab_view',
|
||||
input: 'select',
|
||||
operator: 'eq',
|
||||
unfilteredLabel: 'All',
|
||||
selects: [
|
||||
{ label: 'Virtual', value: true },
|
||||
{ label: 'Physical', value: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
Header: t('Search'),
|
||||
id: 'table_name',
|
||||
input: 'search',
|
||||
operator: 'ct',
|
||||
},
|
||||
];
|
||||
|
||||
const fetchDataset = () =>
|
||||
Promise.all([
|
||||
const fetchDatasetInfo = () => {
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dataset/_info`,
|
||||
}),
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dataset/related/owners`,
|
||||
}),
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dataset/related/database`,
|
||||
}),
|
||||
])
|
||||
.then(
|
||||
([
|
||||
{ json: infoJson = {} },
|
||||
{ json: ownersJson = {} },
|
||||
{ json: databasesJson = {} },
|
||||
]) => {
|
||||
setCurrentOwners(ownersJson.result);
|
||||
setDatabases(databasesJson.result);
|
||||
}).then(
|
||||
({ json: infoJson = {} }) => {
|
||||
setPermissions(infoJson.permissions);
|
||||
setFilterOperators(infoJson.filters);
|
||||
},
|
||||
)
|
||||
.catch(([e1, e2]) => {
|
||||
addDangerToast(t('An error occurred while fetching datasets'));
|
||||
if (e1) {
|
||||
console.error(e1);
|
||||
}
|
||||
if (e2) {
|
||||
console.error(e2);
|
||||
}
|
||||
});
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(t('An error occurred while fetching datasets', errMsg)),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDataset();
|
||||
fetchDatasetInfo();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilters();
|
||||
}, [databases, currentOwners, permissions, filterOperators]);
|
||||
|
||||
const hasPerm = (perm: string) => {
|
||||
if (!permissions.length) {
|
||||
return false;
|
||||
|
@ -220,11 +267,14 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
|||
dashboard_count: json.dashboards.count,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
addDangerToast(
|
||||
t('An error occurred while fetching dataset related data'),
|
||||
.catch(
|
||||
createErrorHandler(errMsg =>
|
||||
t(
|
||||
'An error occurred while fetching dataset related data: %s',
|
||||
errMsg,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
|
@ -477,15 +527,19 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
|||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/dataset/?q=${queryParams}`,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
.then(
|
||||
({ json }) => {
|
||||
setLoading(false);
|
||||
setDatasets(json.result);
|
||||
setDatasetCount(json.count);
|
||||
})
|
||||
.catch(() => {
|
||||
addDangerToast(t('An error occurred while fetching datasets'));
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(
|
||||
t('An error occurred while fetching datasets: %s', errMsg),
|
||||
),
|
||||
),
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
@ -501,10 +555,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
|||
setDatasetCurrentlyDeleting(null);
|
||||
addSuccessToast(t('Deleted: %s', tableName));
|
||||
},
|
||||
(err: any) => {
|
||||
console.error(err);
|
||||
addDangerToast(t('There was an issue deleting %s', tableName));
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(
|
||||
t('There was an issue deleting %s: %s', tableName, errMsg),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -520,10 +575,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
|||
}
|
||||
addSuccessToast(json.message);
|
||||
},
|
||||
(err: any) => {
|
||||
console.error(err);
|
||||
addDangerToast(t('There was an issue deleting the selected datasets'));
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(
|
||||
t('There was an issue deleting the selected datasets: %s', errMsg),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -578,9 +634,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
|||
count={datasetCount}
|
||||
pageSize={PAGE_SIZE}
|
||||
fetchData={fetchData}
|
||||
filters={filterTypes}
|
||||
loading={loading}
|
||||
initialSort={initialSort}
|
||||
filters={currentFilters}
|
||||
bulkActions={bulkActions}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
disableBulkSelect={() => setBulkSelectEnabled(false)}
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* 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 {
|
||||
SupersetClient,
|
||||
SupersetClientResponse,
|
||||
} from '@superset-ui/connection';
|
||||
import rison from 'rison';
|
||||
import getClientErrorObject from 'src/utils/getClientErrorObject';
|
||||
|
||||
export const createFetchRelated = (
|
||||
resource: string,
|
||||
relation: string,
|
||||
handleError: (error: Response) => void,
|
||||
) => async (filterValue = '', pageIndex?: number, pageSize?: number) => {
|
||||
const resourceEndpoint = `/api/v1/${resource}/related/${relation}`;
|
||||
|
||||
try {
|
||||
const queryParams = rison.encode({
|
||||
...(pageIndex ? { page: pageIndex } : {}),
|
||||
...(pageSize ? { page_ize: pageSize } : {}),
|
||||
...(filterValue ? { filter: filterValue } : {}),
|
||||
});
|
||||
const { json = {} } = await SupersetClient.get({
|
||||
endpoint: `${resourceEndpoint}?q=${queryParams}`,
|
||||
});
|
||||
|
||||
return json?.result?.map(
|
||||
({ text: label, value }: { text: string; value: any }) => ({
|
||||
label,
|
||||
value,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const createErrorHandler = (
|
||||
handleError: (errMsg?: string) => void,
|
||||
) => async (e: SupersetClientResponse | string) => {
|
||||
const parsedError = await getClientErrorObject(e);
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
handleError(parsedError.message);
|
||||
};
|
|
@ -29,9 +29,9 @@ import { supersetTheme, ThemeProvider } from '@superset-ui/style';
|
|||
import ErrorBoundary from 'src/components/ErrorBoundary';
|
||||
import Menu from 'src/components/Menu/Menu';
|
||||
import FlashProvider from 'src/components/FlashProvider';
|
||||
import DashboardList from 'src/views/dashboardList/DashboardList';
|
||||
import ChartList from 'src/views/chartList/ChartList';
|
||||
import DatasetList from 'src/views/datasetList/DatasetList';
|
||||
import DashboardList from 'src/views/CRUD/dashboard/DashboardList';
|
||||
import ChartList from 'src/views/CRUD/chart/ChartList';
|
||||
import DatasetList from 'src/views/CRUD/dataset/DatasetList';
|
||||
|
||||
import messageToastReducer from '../messageToasts/reducers';
|
||||
import { initEnhancer } from '../reduxUtils';
|
||||
|
|
|
@ -307,7 +307,6 @@ DEFAULT_FEATURE_FLAGS: Dict[str, bool] = {
|
|||
"SIP_38_VIZ_REARCHITECTURE": False,
|
||||
"TAGGING_SYSTEM": False,
|
||||
"SQLLAB_BACKEND_PERSISTENCE": False,
|
||||
"LIST_VIEWS_SIP34_FILTER_UI": False,
|
||||
}
|
||||
|
||||
# This is merely a default.
|
||||
|
|
|
@ -16,23 +16,33 @@
|
|||
# under the License.
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from flask_appbuilder.api import expose, protect, safe
|
||||
from flask_appbuilder.api import expose, protect, rison, safe
|
||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
from sqlalchemy.exc import NoSuchTableError, SQLAlchemyError
|
||||
|
||||
from superset import event_logger
|
||||
from superset import event_logger, security_manager
|
||||
from superset.databases.decorators import check_datasource_access
|
||||
from superset.databases.schemas import (
|
||||
DatabaseSchemaResponseSchema,
|
||||
SelectStarResponseSchema,
|
||||
TableMetadataResponseSchema,
|
||||
)
|
||||
from superset.models.core import Database
|
||||
from superset.typing import FlaskResponse
|
||||
from superset.utils.core import error_msg_from_exception
|
||||
from superset.views.base_api import BaseSupersetModelRestApi
|
||||
from superset.views.base_api import BaseSupersetModelRestApi, statsd_metrics
|
||||
from superset.views.database.filters import DatabaseFilter
|
||||
from superset.views.database.validators import sqlalchemy_uri_validator
|
||||
|
||||
get_schemas_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page_size": {"type": "integer"},
|
||||
"page": {"type": "integer"},
|
||||
"filter": {"type": "string"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_foreign_keys_metadata(
|
||||
database: Database, table_name: str, schema_name: Optional[str]
|
||||
|
@ -115,12 +125,13 @@ def get_table_metadata(
|
|||
class DatabaseRestApi(BaseSupersetModelRestApi):
|
||||
datamodel = SQLAInterface(Database)
|
||||
|
||||
include_route_methods = {"get_list", "table_metadata", "select_star"}
|
||||
include_route_methods = {"get_list", "table_metadata", "select_star", "schemas"}
|
||||
class_permission_name = "DatabaseView"
|
||||
method_permission_name = {
|
||||
"get_list": "list",
|
||||
"table_metadata": "list",
|
||||
"select_star": "list",
|
||||
"schemas": "list",
|
||||
}
|
||||
resource_name = "database"
|
||||
allow_browser_login = True
|
||||
|
@ -148,7 +159,11 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
|
|||
validators_columns = {"sqlalchemy_uri": sqlalchemy_uri_validator}
|
||||
|
||||
openapi_spec_tag = "Database"
|
||||
apispec_parameter_schemas = {
|
||||
"get_schemas_schema": get_schemas_schema,
|
||||
}
|
||||
openapi_spec_component_schemas = (
|
||||
DatabaseSchemaResponseSchema,
|
||||
TableMetadataResponseSchema,
|
||||
SelectStarResponseSchema,
|
||||
)
|
||||
|
@ -265,3 +280,70 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
|
|||
return self.response(404, message="Table not found on the database")
|
||||
self.incr_stats("success", self.select_star.__name__)
|
||||
return self.response(200, result=result)
|
||||
|
||||
@expose("/schemas/", methods=["GET"])
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@rison(get_schemas_schema)
|
||||
def schemas(self, **kwargs: Any) -> FlaskResponse:
|
||||
"""Get all schemas
|
||||
---
|
||||
get:
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/get_schemas_schema'
|
||||
responses:
|
||||
200:
|
||||
description: Related column data
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DatabaseSchemaResponseSchema"
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
args = kwargs.get("rison", {})
|
||||
# handle pagination
|
||||
page, page_size = self._handle_page_args(args)
|
||||
filter_ = args.get("filter", "")
|
||||
|
||||
_, databases = self.datamodel.query(page=page, page_size=page_size)
|
||||
result = []
|
||||
count = 0
|
||||
if databases:
|
||||
for database in databases:
|
||||
try:
|
||||
schemas = database.get_all_schema_names(
|
||||
cache=database.schema_cache_enabled,
|
||||
cache_timeout=database.schema_cache_timeout,
|
||||
force=False,
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
self.incr_stats("error", self.schemas.__name__)
|
||||
continue
|
||||
|
||||
schemas = security_manager.get_schemas_accessible_by_user(
|
||||
database, schemas
|
||||
)
|
||||
count += len(schemas)
|
||||
for schema in schemas:
|
||||
if filter_:
|
||||
if schema.startswith(filter_):
|
||||
result.append({"text": schema, "value": schema})
|
||||
else:
|
||||
result.append({"text": schema, "value": schema})
|
||||
|
||||
return self.response(200, count=count, result=result)
|
||||
|
|
|
@ -77,3 +77,13 @@ class TableMetadataResponseSchema(Schema):
|
|||
|
||||
class SelectStarResponseSchema(Schema):
|
||||
result = fields.String(description="SQL select star")
|
||||
|
||||
|
||||
class DatabaseSchemaObjectResponseSchema(Schema):
|
||||
value = fields.String(description="Schema name")
|
||||
text = fields.String(description="Schema display name")
|
||||
|
||||
|
||||
class DatabaseSchemaResponseSchema(Schema):
|
||||
count = fields.Integer(description="The total number of schemas")
|
||||
result = fields.Nested(DatabaseSchemaObjectResponseSchema)
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
# isort:skip_file
|
||||
"""Unit tests for Superset"""
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
import prison
|
||||
from sqlalchemy.sql import func
|
||||
|
@ -220,3 +221,41 @@ class TestDatabaseApi(SupersetTestCase):
|
|||
uri = f"api/v1/database/{example_db.id}/select_star/table_does_not_exist/"
|
||||
rv = self.client.get(uri)
|
||||
self.assertEqual(rv.status_code, 404)
|
||||
|
||||
def test_schemas(self):
|
||||
self.login("admin")
|
||||
dbs = db.session.query(Database).all()
|
||||
schemas = []
|
||||
for database in dbs:
|
||||
schemas += database.get_all_schema_names()
|
||||
|
||||
rv = self.client.get("api/v1/database/schemas/")
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
self.assertEqual(len(schemas), response["count"])
|
||||
self.assertEqual(schemas[0], response["result"][0]["value"])
|
||||
|
||||
rv = self.client.get(
|
||||
f"api/v1/database/schemas/?q={prison.dumps({'filter': 'foo'})}"
|
||||
)
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
self.assertEqual(0, len(response["result"]))
|
||||
|
||||
rv = self.client.get(
|
||||
f"api/v1/database/schemas/?q={prison.dumps({'page': 0, 'page_size': 25})}"
|
||||
)
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
self.assertEqual(len(schemas), len(response["result"]))
|
||||
|
||||
rv = self.client.get(
|
||||
f"api/v1/database/schemas/?q={prison.dumps({'page': 1, 'page_size': 25})}"
|
||||
)
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
self.assertEqual(0, len(response["result"]))
|
||||
|
||||
@mock.patch("superset.security_manager.get_schemas_accessible_by_user")
|
||||
def test_schemas_no_access(self, mock_get_schemas_accessible_by_user):
|
||||
mock_get_schemas_accessible_by_user.return_value = []
|
||||
self.login("admin")
|
||||
rv = self.client.get("api/v1/database/schemas/")
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
self.assertEqual(0, response["count"])
|
||||
|
|
Loading…
Reference in New Issue