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 React from 'react';
|
||||||
import { mount, shallow } from 'enzyme';
|
import { mount, shallow } from 'enzyme';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { MenuItem } from 'react-bootstrap';
|
|
||||||
import { QueryParamProvider } from 'use-query-params';
|
import { QueryParamProvider } from 'use-query-params';
|
||||||
import { supersetTheme, ThemeProvider } from '@superset-ui/style';
|
import { supersetTheme, ThemeProvider } from '@superset-ui/style';
|
||||||
|
|
||||||
|
@ -42,6 +41,7 @@ function makeMockLocation(query) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchSelectsMock = jest.fn(() => []);
|
||||||
const mockedProps = {
|
const mockedProps = {
|
||||||
title: 'Data Table',
|
title: 'Data Table',
|
||||||
columns: [
|
columns: [
|
||||||
|
@ -60,10 +60,26 @@ const mockedProps = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
filters: [
|
filters: [
|
||||||
|
{
|
||||||
|
Header: 'ID',
|
||||||
|
id: 'id',
|
||||||
|
input: 'select',
|
||||||
|
selects: [{ label: 'foo', value: 'bar' }],
|
||||||
|
operator: 'eq',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Header: 'Name',
|
Header: 'Name',
|
||||||
id: '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: [
|
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', () => {
|
it('renders pagination controls', () => {
|
||||||
expect(wrapper.find(Pagination).exists()).toBe(true);
|
expect(wrapper.find(Pagination).exists()).toBe(true);
|
||||||
expect(wrapper.find(Pagination.Prev).exists()).toBe(true);
|
expect(wrapper.find(Pagination.Prev).exists()).toBe(true);
|
||||||
|
@ -212,26 +175,20 @@ Array [
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
|
||||||
expect(mockedProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
|
expect(mockedProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
Object {
|
Object {
|
||||||
"filters": Array [
|
"filters": Array [],
|
||||||
Object {
|
"pageIndex": 1,
|
||||||
"id": "name",
|
"pageSize": 1,
|
||||||
"operator": "sw",
|
"sortBy": Array [
|
||||||
"value": "foo",
|
Object {
|
||||||
},
|
"desc": false,
|
||||||
],
|
"id": "id",
|
||||||
"pageIndex": 1,
|
},
|
||||||
"pageSize": 1,
|
],
|
||||||
"sortBy": Array [
|
},
|
||||||
Object {
|
]
|
||||||
"desc": false,
|
`);
|
||||||
"id": "id",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles bulk actions on 1 row', () => {
|
it('handles bulk actions on 1 row', () => {
|
||||||
|
@ -339,46 +296,6 @@ Array [
|
||||||
'"Invalid filter config, some_column is not present in columns"',
|
'"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', () => {
|
it('renders UI filters', () => {
|
||||||
expect(wrapper.find(ListViewFilters)).toHaveLength(1);
|
expect(wrapper.find(ListViewFilters)).toHaveLength(1);
|
||||||
|
@ -407,43 +324,53 @@ describe('ListView with new UI filters', () => {
|
||||||
wrapper.find('[data-test="search-input"]').last().props().onBlur();
|
wrapper.find('[data-test="search-input"]').last().props().onBlur();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(newFiltersProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
|
expect(mockedProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
Object {
|
Object {
|
||||||
"filters": Array [
|
"filters": Array [
|
||||||
Object {
|
Object {
|
||||||
"id": "id",
|
"id": "id",
|
||||||
"operator": "eq",
|
"operator": "eq",
|
||||||
"value": "bar",
|
"value": "bar",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"pageIndex": 0,
|
"pageIndex": 0,
|
||||||
"pageSize": 1,
|
"pageSize": 1,
|
||||||
"sortBy": Array [],
|
"sortBy": Array [
|
||||||
},
|
Object {
|
||||||
]
|
"desc": false,
|
||||||
`);
|
"id": "id",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
|
||||||
expect(newFiltersProps.fetchData.mock.calls[1]).toMatchInlineSnapshot(`
|
expect(mockedProps.fetchData.mock.calls[1]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
Object {
|
Object {
|
||||||
"filters": Array [
|
"filters": Array [
|
||||||
Object {
|
Object {
|
||||||
"id": "id",
|
"id": "id",
|
||||||
"operator": "eq",
|
"operator": "eq",
|
||||||
"value": "bar",
|
"value": "bar",
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"id": "name",
|
"id": "name",
|
||||||
"operator": "ct",
|
"operator": "ct",
|
||||||
"value": "something",
|
"value": "something",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"pageIndex": 0,
|
"pageIndex": 0,
|
||||||
"pageSize": 1,
|
"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 fetchMock from 'fetch-mock';
|
||||||
import { supersetTheme, ThemeProvider } from '@superset-ui/style';
|
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';
|
import ListView from 'src/components/ListView/ListView';
|
||||||
|
|
||||||
// store needed for withToasts(ChartTable)
|
// store needed for withToasts(ChartTable)
|
||||||
|
@ -48,13 +48,6 @@ const mockCharts = [...new Array(3)].map((_, i) => ({
|
||||||
|
|
||||||
fetchMock.get(chartsInfoEndpoint, {
|
fetchMock.get(chartsInfoEndpoint, {
|
||||||
permissions: ['can_list', 'can_edit'],
|
permissions: ['can_list', 'can_edit'],
|
||||||
filters: {
|
|
||||||
slice_name: [],
|
|
||||||
description: [],
|
|
||||||
viz_type: [],
|
|
||||||
datasource_name: [],
|
|
||||||
owners: [],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
fetchMock.get(chartssOwnersEndpoint, {
|
fetchMock.get(chartssOwnersEndpoint, {
|
||||||
result: [],
|
result: [],
|
||||||
|
@ -95,11 +88,6 @@ describe('ChartList', () => {
|
||||||
expect(callsI).toHaveLength(1);
|
expect(callsI).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches owners', () => {
|
|
||||||
const callsO = fetchMock.calls(/chart\/related\/owners/);
|
|
||||||
expect(callsO).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches data', () => {
|
it('fetches data', () => {
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
const callsD = fetchMock.calls(/chart\/\?q/);
|
const callsD = fetchMock.calls(/chart\/\?q/);
|
|
@ -23,7 +23,7 @@ import configureStore from 'redux-mock-store';
|
||||||
import fetchMock from 'fetch-mock';
|
import fetchMock from 'fetch-mock';
|
||||||
import { supersetTheme, ThemeProvider } from '@superset-ui/style';
|
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 ListView from 'src/components/ListView/ListView';
|
||||||
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
|
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
|
||||||
|
|
||||||
|
@ -50,12 +50,6 @@ const mockDashboards = [...new Array(3)].map((_, i) => ({
|
||||||
|
|
||||||
fetchMock.get(dashboardsInfoEndpoint, {
|
fetchMock.get(dashboardsInfoEndpoint, {
|
||||||
permissions: ['can_list', 'can_edit'],
|
permissions: ['can_list', 'can_edit'],
|
||||||
filters: {
|
|
||||||
dashboard_title: [],
|
|
||||||
slug: [],
|
|
||||||
owners: [],
|
|
||||||
published: [],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
fetchMock.get(dashboardOwnersEndpoint, {
|
fetchMock.get(dashboardOwnersEndpoint, {
|
||||||
result: [],
|
result: [],
|
||||||
|
@ -86,11 +80,6 @@ describe('DashboardList', () => {
|
||||||
expect(callsI).toHaveLength(1);
|
expect(callsI).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches owners', () => {
|
|
||||||
const callsO = fetchMock.calls(/dashboard\/related\/owners/);
|
|
||||||
expect(callsO).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches data', () => {
|
it('fetches data', () => {
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
const callsD = fetchMock.calls(/dashboard\/\?q/);
|
const callsD = fetchMock.calls(/dashboard\/\?q/);
|
|
@ -23,7 +23,7 @@ import configureStore from 'redux-mock-store';
|
||||||
import fetchMock from 'fetch-mock';
|
import fetchMock from 'fetch-mock';
|
||||||
import { supersetTheme, ThemeProvider } from '@superset-ui/style';
|
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 ListView from 'src/components/ListView/ListView';
|
||||||
import Button from 'src/components/Button';
|
import Button from 'src/components/Button';
|
||||||
import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
|
import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
|
||||||
|
@ -54,13 +54,6 @@ const mockdatasets = [...new Array(3)].map((_, i) => ({
|
||||||
|
|
||||||
fetchMock.get(datasetsInfoEndpoint, {
|
fetchMock.get(datasetsInfoEndpoint, {
|
||||||
permissions: ['can_list', 'can_edit', 'can_add', 'can_delete'],
|
permissions: ['can_list', 'can_edit', 'can_add', 'can_delete'],
|
||||||
filters: {
|
|
||||||
database: [],
|
|
||||||
schema: [],
|
|
||||||
table_name: [],
|
|
||||||
owners: [],
|
|
||||||
is_sqllab_view: [],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
fetchMock.get(datasetsOwnersEndpoint, {
|
fetchMock.get(datasetsOwnersEndpoint, {
|
||||||
result: [],
|
result: [],
|
||||||
|
@ -105,11 +98,6 @@ describe('DatasetList', () => {
|
||||||
expect(callsI).toHaveLength(1);
|
expect(callsI).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches owners', () => {
|
|
||||||
const callsO = fetchMock.calls(/dataset\/related\/owners/);
|
|
||||||
expect(callsO).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches data', () => {
|
it('fetches data', () => {
|
||||||
const callsD = fetchMock.calls(/dataset\/\?q/);
|
const callsD = fetchMock.calls(/dataset\/\?q/);
|
||||||
expect(callsD).toHaveLength(1);
|
expect(callsD).toHaveLength(1);
|
|
@ -22,8 +22,8 @@ import { styled, supersetTheme } from '@superset-ui/style';
|
||||||
import { t, tn } from '@superset-ui/translation';
|
import { t, tn } from '@superset-ui/translation';
|
||||||
|
|
||||||
import { noOp } from 'src/utils/common';
|
import { noOp } from 'src/utils/common';
|
||||||
|
import Button from 'src/views/CRUD/dataset/Button';
|
||||||
import Icon from '../Icon';
|
import Icon from '../Icon';
|
||||||
import Button from '../../views/datasetList/Button';
|
|
||||||
import { ErrorMessageComponentProps } from './types';
|
import { ErrorMessageComponentProps } from './types';
|
||||||
import CopyToClipboard from '../CopyToClipboard';
|
import CopyToClipboard from '../CopyToClipboard';
|
||||||
import IssueCode from './IssueCode';
|
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 IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
|
||||||
import TableCollection from './TableCollection';
|
import TableCollection from './TableCollection';
|
||||||
import Pagination from './Pagination';
|
import Pagination from './Pagination';
|
||||||
import { FilterMenu, FilterInputs } from './LegacyFilters';
|
|
||||||
import FilterControls from './Filters';
|
import FilterControls from './Filters';
|
||||||
import { FetchDataConfig, Filters, SortColumn } from './types';
|
import { FetchDataConfig, Filters, SortColumn } from './types';
|
||||||
import { ListViewError, useListViewState } from './utils';
|
import { ListViewError, useListViewState } from './utils';
|
||||||
|
@ -198,7 +197,6 @@ export interface ListViewProps {
|
||||||
onSelect: (rows: any[]) => any;
|
onSelect: (rows: any[]) => any;
|
||||||
type?: 'primary' | 'secondary' | 'danger';
|
type?: 'primary' | 'secondary' | 'danger';
|
||||||
}>;
|
}>;
|
||||||
isSIP34FilterUIEnabled?: boolean;
|
|
||||||
bulkSelectEnabled?: boolean;
|
bulkSelectEnabled?: boolean;
|
||||||
disableBulkSelect?: () => void;
|
disableBulkSelect?: () => void;
|
||||||
renderBulkSelectCopy?: (selects: any[]) => React.ReactNode;
|
renderBulkSelectCopy?: (selects: any[]) => React.ReactNode;
|
||||||
|
@ -263,7 +261,6 @@ const ListView: FunctionComponent<ListViewProps> = ({
|
||||||
className = '',
|
className = '',
|
||||||
filters = [],
|
filters = [],
|
||||||
bulkActions = [],
|
bulkActions = [],
|
||||||
isSIP34FilterUIEnabled = false,
|
|
||||||
bulkSelectEnabled = false,
|
bulkSelectEnabled = false,
|
||||||
disableBulkSelect = () => {},
|
disableBulkSelect = () => {},
|
||||||
renderBulkSelectCopy = selected => t('%s Selected', selected.length),
|
renderBulkSelectCopy = selected => t('%s Selected', selected.length),
|
||||||
|
@ -276,12 +273,7 @@ const ListView: FunctionComponent<ListViewProps> = ({
|
||||||
prepareRow,
|
prepareRow,
|
||||||
pageCount = 1,
|
pageCount = 1,
|
||||||
gotoPage,
|
gotoPage,
|
||||||
removeFilterAndApply,
|
|
||||||
setInternalFilters,
|
|
||||||
updateInternalFilter,
|
|
||||||
applyFilterValue,
|
applyFilterValue,
|
||||||
applyFilters,
|
|
||||||
filtersApplied,
|
|
||||||
selectedFlatRows,
|
selectedFlatRows,
|
||||||
toggleAllRowsSelected,
|
toggleAllRowsSelected,
|
||||||
state: { pageIndex, pageSize, internalFilters },
|
state: { pageIndex, pageSize, internalFilters },
|
||||||
|
@ -294,7 +286,7 @@ const ListView: FunctionComponent<ListViewProps> = ({
|
||||||
fetchData,
|
fetchData,
|
||||||
initialPageSize,
|
initialPageSize,
|
||||||
initialSort,
|
initialSort,
|
||||||
initialFilters: isSIP34FilterUIEnabled ? filters : [],
|
initialFilters: filters,
|
||||||
});
|
});
|
||||||
const filterable = Boolean(filters.length);
|
const filterable = Boolean(filters.length);
|
||||||
if (filterable) {
|
if (filterable) {
|
||||||
|
@ -317,30 +309,7 @@ const ListView: FunctionComponent<ListViewProps> = ({
|
||||||
<ListViewStyles>
|
<ListViewStyles>
|
||||||
<div className={`superset-list-view ${className}`}>
|
<div className={`superset-list-view ${className}`}>
|
||||||
<div className="header">
|
<div className="header">
|
||||||
{!isSIP34FilterUIEnabled && filterable && (
|
{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 && (
|
|
||||||
<FilterControls
|
<FilterControls
|
||||||
filters={filters}
|
filters={filters}
|
||||||
internalFilters={internalFilters}
|
internalFilters={internalFilters}
|
||||||
|
|
|
@ -32,7 +32,19 @@ export interface Filter {
|
||||||
Header: string;
|
Header: string;
|
||||||
id: string;
|
id: string;
|
||||||
operators?: SelectOption[];
|
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';
|
input?: 'text' | 'textarea' | 'select' | 'checkbox' | 'search';
|
||||||
unfilteredLabel?: string;
|
unfilteredLabel?: string;
|
||||||
selects?: SelectOption[];
|
selects?: SelectOption[];
|
||||||
|
@ -63,19 +75,3 @@ export interface FetchDataConfig {
|
||||||
export interface InternalFilter extends FilterValue {
|
export interface InternalFilter extends FilterValue {
|
||||||
Header?: string;
|
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]);
|
}, [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) => {
|
const applyFilterValue = (index: number, value: any) => {
|
||||||
// skip redunundant updates
|
// skip redunundant updates
|
||||||
if (internalFilters[index].value === value) {
|
if (internalFilters[index].value === value) {
|
||||||
|
@ -249,18 +237,9 @@ export function useListViewState({
|
||||||
gotoPage(0); // clear pagination on filter
|
gotoPage(0); // clear pagination on filter
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFilterAndApply = (index: number) => {
|
|
||||||
const updated = removeFromList(internalFilters, index);
|
|
||||||
setInternalFilters(updated);
|
|
||||||
setAllFilters(convertFilters(updated));
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
applyFilters: () => setAllFilters(convertFilters(internalFilters)),
|
|
||||||
removeFilterAndApply,
|
|
||||||
canNextPage,
|
canNextPage,
|
||||||
canPreviousPage,
|
canPreviousPage,
|
||||||
filtersApplied,
|
|
||||||
getTableBodyProps,
|
getTableBodyProps,
|
||||||
getTableProps,
|
getTableProps,
|
||||||
gotoPage,
|
gotoPage,
|
||||||
|
@ -270,10 +249,8 @@ export function useListViewState({
|
||||||
rows,
|
rows,
|
||||||
selectedFlatRows,
|
selectedFlatRows,
|
||||||
setAllFilters,
|
setAllFilters,
|
||||||
setInternalFilters,
|
|
||||||
state: { pageIndex, pageSize, sortBy, filters, internalFilters },
|
state: { pageIndex, pageSize, sortBy, filters, internalFilters },
|
||||||
toggleAllRowsSelected,
|
toggleAllRowsSelected,
|
||||||
updateInternalFilter,
|
|
||||||
applyFilterValue,
|
applyFilterValue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ import React from 'react';
|
||||||
import styled from '@superset-ui/style';
|
import styled from '@superset-ui/style';
|
||||||
import { Modal as BaseModal } from 'react-bootstrap';
|
import { Modal as BaseModal } from 'react-bootstrap';
|
||||||
import { t } from '@superset-ui/translation';
|
import { t } from '@superset-ui/translation';
|
||||||
import Button from '../views/datasetList/Button';
|
import Button from 'src/views/CRUD/dataset/Button';
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
|
@ -26,7 +26,6 @@ export enum FeatureFlag {
|
||||||
ESTIMATE_QUERY_COST = 'ESTIMATE_QUERY_COST',
|
ESTIMATE_QUERY_COST = 'ESTIMATE_QUERY_COST',
|
||||||
SHARE_QUERIES_VIA_KV_STORE = 'SHARE_QUERIES_VIA_KV_STORE',
|
SHARE_QUERIES_VIA_KV_STORE = 'SHARE_QUERIES_VIA_KV_STORE',
|
||||||
SQLLAB_BACKEND_PERSISTENCE = 'SQLLAB_BACKEND_PERSISTENCE',
|
SQLLAB_BACKEND_PERSISTENCE = 'SQLLAB_BACKEND_PERSISTENCE',
|
||||||
LIST_VIEWS_SIP34_FILTER_UI = 'LIST_VIEWS_SIP34_FILTER_UI',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeatureFlagMap = {
|
export type FeatureFlagMap = {
|
||||||
|
|
|
@ -22,21 +22,15 @@ import { getChartMetadataRegistry } from '@superset-ui/chart';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import rison from 'rison';
|
import rison from 'rison';
|
||||||
// @ts-ignore
|
import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils';
|
||||||
import { Panel } from 'react-bootstrap';
|
|
||||||
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
||||||
import SubMenu from 'src/components/Menu/SubMenu';
|
import SubMenu from 'src/components/Menu/SubMenu';
|
||||||
import Icon from 'src/components/Icon';
|
import Icon from 'src/components/Icon';
|
||||||
import ListView, { ListViewProps } from 'src/components/ListView/ListView';
|
import ListView, { ListViewProps } from 'src/components/ListView/ListView';
|
||||||
import {
|
import { FetchDataConfig, Filters } from 'src/components/ListView/types';
|
||||||
FetchDataConfig,
|
|
||||||
FilterOperatorMap,
|
|
||||||
Filters,
|
|
||||||
} from 'src/components/ListView/types';
|
|
||||||
import withToasts from 'src/messageToasts/enhancers/withToasts';
|
import withToasts from 'src/messageToasts/enhancers/withToasts';
|
||||||
import PropertiesModal, { Slice } from 'src/explore/components/PropertiesModal';
|
import PropertiesModal, { Slice } from 'src/explore/components/PropertiesModal';
|
||||||
import Chart from 'src/types/Chart';
|
import Chart from 'src/types/Chart';
|
||||||
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
|
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
@ -49,8 +43,6 @@ interface State {
|
||||||
bulkSelectEnabled: boolean;
|
bulkSelectEnabled: boolean;
|
||||||
chartCount: number;
|
chartCount: number;
|
||||||
charts: any[];
|
charts: any[];
|
||||||
filterOperators: FilterOperatorMap;
|
|
||||||
filters: Filters;
|
|
||||||
lastFetchDataConfig: FetchDataConfig | null;
|
lastFetchDataConfig: FetchDataConfig | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
|
@ -58,7 +50,23 @@ interface State {
|
||||||
// In future it would be better to have a unified Chart entity.
|
// In future it would be better to have a unified Chart entity.
|
||||||
sliceCurrentlyEditing: Slice | null;
|
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> {
|
class ChartList extends React.PureComponent<Props, State> {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
addDangerToast: PropTypes.func.isRequired,
|
addDangerToast: PropTypes.func.isRequired,
|
||||||
|
@ -68,8 +76,6 @@ class ChartList extends React.PureComponent<Props, State> {
|
||||||
bulkSelectEnabled: false,
|
bulkSelectEnabled: false,
|
||||||
chartCount: 0,
|
chartCount: 0,
|
||||||
charts: [],
|
charts: [],
|
||||||
filterOperators: {},
|
|
||||||
filters: [],
|
|
||||||
lastFetchDataConfig: null,
|
lastFetchDataConfig: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
@ -81,20 +87,15 @@ class ChartList extends React.PureComponent<Props, State> {
|
||||||
endpoint: `/api/v1/chart/_info`,
|
endpoint: `/api/v1/chart/_info`,
|
||||||
}).then(
|
}).then(
|
||||||
({ json: infoJson = {} }) => {
|
({ json: infoJson = {} }) => {
|
||||||
this.setState(
|
this.setState({
|
||||||
{
|
permissions: infoJson.permissions,
|
||||||
filterOperators: infoJson.filters,
|
});
|
||||||
permissions: infoJson.permissions,
|
|
||||||
},
|
|
||||||
this.updateFilters,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
e => {
|
createErrorHandler(errMsg =>
|
||||||
this.props.addDangerToast(
|
this.props.addDangerToast(
|
||||||
t('An error occurred while fetching charts: %s', e.statusText),
|
t('An error occurred while fetching chart info: %s', errMsg),
|
||||||
);
|
),
|
||||||
console.error(e);
|
),
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,10 +107,6 @@ class ChartList extends React.PureComponent<Props, State> {
|
||||||
return this.hasPerm('can_delete');
|
return this.hasPerm('can_delete');
|
||||||
}
|
}
|
||||||
|
|
||||||
get isSIP34FilterUIEnabled() {
|
|
||||||
return isFeatureEnabled(FeatureFlag.LIST_VIEWS_SIP34_FILTER_UI);
|
|
||||||
}
|
|
||||||
|
|
||||||
initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
|
initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
|
||||||
|
|
||||||
columns = [
|
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) => {
|
hasPerm = (perm: string) => {
|
||||||
if (!this.state.permissions.length) {
|
if (!this.state.permissions.length) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -295,15 +349,11 @@ class ChartList extends React.PureComponent<Props, State> {
|
||||||
}
|
}
|
||||||
this.props.addSuccessToast(json.message);
|
this.props.addSuccessToast(json.message);
|
||||||
},
|
},
|
||||||
(err: any) => {
|
createErrorHandler(errMsg =>
|
||||||
console.error(err);
|
|
||||||
this.props.addDangerToast(
|
this.props.addDangerToast(
|
||||||
t(
|
t('There was an issue deleting the selected charts: %s', errMsg),
|
||||||
'There was an issue deleting the selected charts: %s',
|
),
|
||||||
err.statusText,
|
),
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -354,171 +404,27 @@ class ChartList extends React.PureComponent<Props, State> {
|
||||||
return SupersetClient.get({
|
return SupersetClient.get({
|
||||||
endpoint: `/api/v1/chart/?q=${queryParams}`,
|
endpoint: `/api/v1/chart/?q=${queryParams}`,
|
||||||
})
|
})
|
||||||
.then(({ json = {} }) => {
|
.then(
|
||||||
this.setState({ charts: json.result, chartCount: json.count });
|
({ json = {} }) => {
|
||||||
})
|
this.setState({ charts: json.result, chartCount: json.count });
|
||||||
.catch(e => {
|
},
|
||||||
console.log(e.body);
|
createErrorHandler(errMsg =>
|
||||||
this.props.addDangerToast(
|
this.props.addDangerToast(
|
||||||
t('An error occurred while fetching charts: %s', e.statusText),
|
t('An error occurred while fetching charts: %s', errMsg),
|
||||||
);
|
),
|
||||||
})
|
),
|
||||||
|
)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.setState({ loading: false });
|
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() {
|
render() {
|
||||||
const {
|
const {
|
||||||
bulkSelectEnabled,
|
bulkSelectEnabled,
|
||||||
charts,
|
charts,
|
||||||
chartCount,
|
chartCount,
|
||||||
loading,
|
loading,
|
||||||
filters,
|
|
||||||
sliceCurrentlyEditing,
|
sliceCurrentlyEditing,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
return (
|
return (
|
||||||
|
@ -536,9 +442,9 @@ class ChartList extends React.PureComponent<Props, State> {
|
||||||
/>
|
/>
|
||||||
{sliceCurrentlyEditing && (
|
{sliceCurrentlyEditing && (
|
||||||
<PropertiesModal
|
<PropertiesModal
|
||||||
show
|
|
||||||
onHide={this.closeChartEditModal}
|
onHide={this.closeChartEditModal}
|
||||||
onSave={this.handleChartUpdated}
|
onSave={this.handleChartUpdated}
|
||||||
|
show
|
||||||
slice={sliceCurrentlyEditing}
|
slice={sliceCurrentlyEditing}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -563,19 +469,18 @@ class ChartList extends React.PureComponent<Props, State> {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListView
|
<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}
|
bulkActions={bulkActions}
|
||||||
bulkSelectEnabled={bulkSelectEnabled}
|
bulkSelectEnabled={bulkSelectEnabled}
|
||||||
|
className="chart-list-view"
|
||||||
|
columns={this.columns}
|
||||||
|
count={chartCount}
|
||||||
|
data={charts}
|
||||||
disableBulkSelect={this.toggleBulkSelect}
|
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 PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import rison from 'rison';
|
import rison from 'rison';
|
||||||
// @ts-ignore
|
import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils';
|
||||||
import { Panel } from 'react-bootstrap';
|
|
||||||
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
||||||
import SubMenu from 'src/components/Menu/SubMenu';
|
import SubMenu from 'src/components/Menu/SubMenu';
|
||||||
import ListView, { ListViewProps } from 'src/components/ListView/ListView';
|
import ListView, { ListViewProps } from 'src/components/ListView/ListView';
|
||||||
import ExpandableList from 'src/components/ExpandableList';
|
import ExpandableList from 'src/components/ExpandableList';
|
||||||
import {
|
import { FetchDataConfig, Filters } from 'src/components/ListView/types';
|
||||||
FetchDataConfig,
|
|
||||||
FilterOperatorMap,
|
|
||||||
Filters,
|
|
||||||
} from 'src/components/ListView/types';
|
|
||||||
import withToasts from 'src/messageToasts/enhancers/withToasts';
|
import withToasts from 'src/messageToasts/enhancers/withToasts';
|
||||||
import Icon from 'src/components/Icon';
|
import Icon from 'src/components/Icon';
|
||||||
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
|
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
|
||||||
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
|
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
@ -49,8 +43,6 @@ interface State {
|
||||||
dashboardCount: number;
|
dashboardCount: number;
|
||||||
dashboards: any[];
|
dashboards: any[];
|
||||||
dashboardToEdit: Dashboard | null;
|
dashboardToEdit: Dashboard | null;
|
||||||
filterOperators: FilterOperatorMap;
|
|
||||||
filters: Filters;
|
|
||||||
lastFetchDataConfig: FetchDataConfig | null;
|
lastFetchDataConfig: FetchDataConfig | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
|
@ -77,8 +69,6 @@ class DashboardList extends React.PureComponent<Props, State> {
|
||||||
dashboardCount: 0,
|
dashboardCount: 0,
|
||||||
dashboards: [],
|
dashboards: [],
|
||||||
dashboardToEdit: null,
|
dashboardToEdit: null,
|
||||||
filterOperators: {},
|
|
||||||
filters: [],
|
|
||||||
lastFetchDataConfig: null,
|
lastFetchDataConfig: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
@ -89,23 +79,15 @@ class DashboardList extends React.PureComponent<Props, State> {
|
||||||
endpoint: `/api/v1/dashboard/_info`,
|
endpoint: `/api/v1/dashboard/_info`,
|
||||||
}).then(
|
}).then(
|
||||||
({ json: infoJson = {} }) => {
|
({ json: infoJson = {} }) => {
|
||||||
this.setState(
|
this.setState({
|
||||||
{
|
permissions: infoJson.permissions,
|
||||||
filterOperators: infoJson.filters,
|
});
|
||||||
permissions: infoJson.permissions,
|
|
||||||
},
|
|
||||||
this.updateFilters,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
e => {
|
createErrorHandler(errMsg =>
|
||||||
this.props.addDangerToast(
|
this.props.addDangerToast(
|
||||||
t(
|
t('An error occurred while fetching Dashboards: %s, %s', errMsg),
|
||||||
'An error occurred while fetching Dashboards: %s, %s',
|
),
|
||||||
e.statusText,
|
),
|
||||||
),
|
|
||||||
);
|
|
||||||
console.error(e);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -121,10 +103,6 @@ class DashboardList extends React.PureComponent<Props, State> {
|
||||||
return this.hasPerm('can_mulexport');
|
return this.hasPerm('can_mulexport');
|
||||||
}
|
}
|
||||||
|
|
||||||
get isSIP34FilterUIEnabled() {
|
|
||||||
return isFeatureEnabled(FeatureFlag.LIST_VIEWS_SIP34_FILTER_UI);
|
|
||||||
}
|
|
||||||
|
|
||||||
initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
|
initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
|
||||||
|
|
||||||
columns = [
|
columns = [
|
||||||
|
@ -260,6 +238,46 @@ class DashboardList extends React.PureComponent<Props, State> {
|
||||||
this.setState({ bulkSelectEnabled: !this.state.bulkSelectEnabled });
|
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) => {
|
hasPerm = (perm: string) => {
|
||||||
if (!this.state.permissions.length) {
|
if (!this.state.permissions.length) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -278,8 +296,8 @@ class DashboardList extends React.PureComponent<Props, State> {
|
||||||
this.setState({ loading: true });
|
this.setState({ loading: true });
|
||||||
return SupersetClient.get({
|
return SupersetClient.get({
|
||||||
endpoint: `/api/v1/dashboard/${edits.id}`,
|
endpoint: `/api/v1/dashboard/${edits.id}`,
|
||||||
})
|
}).then(
|
||||||
.then(({ json = {} }) => {
|
({ json = {} }) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
dashboards: this.state.dashboards.map(dashboard => {
|
dashboards: this.state.dashboards.map(dashboard => {
|
||||||
if (dashboard.id === json.id) {
|
if (dashboard.id === json.id) {
|
||||||
|
@ -289,12 +307,13 @@ class DashboardList extends React.PureComponent<Props, State> {
|
||||||
}),
|
}),
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
})
|
},
|
||||||
.catch(e => {
|
createErrorHandler(errMsg =>
|
||||||
this.props.addDangerToast(
|
this.props.addDangerToast(
|
||||||
t('An error occurred while fetching dashboards: %s', e.statusText),
|
t('An error occurred while fetching dashboards: %s', errMsg),
|
||||||
);
|
),
|
||||||
});
|
),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleDashboardDelete = ({
|
handleDashboardDelete = ({
|
||||||
|
@ -311,12 +330,11 @@ class DashboardList extends React.PureComponent<Props, State> {
|
||||||
}
|
}
|
||||||
this.props.addSuccessToast(t('Deleted: %s', dashboardTitle));
|
this.props.addSuccessToast(t('Deleted: %s', dashboardTitle));
|
||||||
},
|
},
|
||||||
(err: any) => {
|
createErrorHandler(errMsg =>
|
||||||
console.error(err);
|
|
||||||
this.props.addDangerToast(
|
this.props.addDangerToast(
|
||||||
t('There was an issue deleting %s', dashboardTitle),
|
t('There was an issue deleting %s: %s', dashboardTitle, errMsg),
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
handleBulkDashboardDelete = (dashboards: Dashboard[]) => {
|
handleBulkDashboardDelete = (dashboards: Dashboard[]) => {
|
||||||
|
@ -332,15 +350,11 @@ class DashboardList extends React.PureComponent<Props, State> {
|
||||||
}
|
}
|
||||||
this.props.addSuccessToast(json.message);
|
this.props.addSuccessToast(json.message);
|
||||||
},
|
},
|
||||||
(err: any) => {
|
createErrorHandler(errMsg =>
|
||||||
console.error(err);
|
|
||||||
this.props.addDangerToast(
|
this.props.addDangerToast(
|
||||||
t(
|
t('There was an issue deleting the selected dashboards: ', errMsg),
|
||||||
'There was an issue deleting the selected dashboards: ',
|
),
|
||||||
err.statusText,
|
),
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -380,137 +394,31 @@ class DashboardList extends React.PureComponent<Props, State> {
|
||||||
return SupersetClient.get({
|
return SupersetClient.get({
|
||||||
endpoint: `/api/v1/dashboard/?q=${queryParams}`,
|
endpoint: `/api/v1/dashboard/?q=${queryParams}`,
|
||||||
})
|
})
|
||||||
.then(({ json = {} }) => {
|
.then(
|
||||||
this.setState({ dashboards: json.result, dashboardCount: json.count });
|
({ json = {} }) => {
|
||||||
})
|
this.setState({
|
||||||
.catch(e => {
|
dashboards: json.result,
|
||||||
this.props.addDangerToast(
|
dashboardCount: json.count,
|
||||||
t('An error occurred while fetching dashboards: %s', e.statusText),
|
});
|
||||||
);
|
},
|
||||||
})
|
createErrorHandler(errMsg =>
|
||||||
|
this.props.addDangerToast(
|
||||||
|
t('An error occurred while fetching dashboards: %s', errMsg),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.setState({ loading: false });
|
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() {
|
render() {
|
||||||
const {
|
const {
|
||||||
bulkSelectEnabled,
|
bulkSelectEnabled,
|
||||||
dashboardCount,
|
|
||||||
dashboards,
|
dashboards,
|
||||||
dashboardToEdit,
|
dashboardCount,
|
||||||
filters,
|
|
||||||
loading,
|
loading,
|
||||||
|
dashboardToEdit,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -554,26 +462,25 @@ class DashboardList extends React.PureComponent<Props, State> {
|
||||||
<>
|
<>
|
||||||
{dashboardToEdit && (
|
{dashboardToEdit && (
|
||||||
<PropertiesModal
|
<PropertiesModal
|
||||||
show
|
|
||||||
dashboardId={dashboardToEdit.id}
|
dashboardId={dashboardToEdit.id}
|
||||||
onHide={() => this.setState({ dashboardToEdit: null })}
|
|
||||||
onDashboardSave={this.handleDashboardEdit}
|
onDashboardSave={this.handleDashboardEdit}
|
||||||
|
onHide={() => this.setState({ dashboardToEdit: null })}
|
||||||
|
show
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ListView
|
<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}
|
bulkActions={bulkActions}
|
||||||
bulkSelectEnabled={bulkSelectEnabled}
|
bulkSelectEnabled={bulkSelectEnabled}
|
||||||
|
className="dashboard-list-view"
|
||||||
|
columns={this.columns}
|
||||||
|
count={dashboardCount}
|
||||||
|
data={dashboards}
|
||||||
disableBulkSelect={this.toggleBulkSelect}
|
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 Icon from 'src/components/Icon';
|
||||||
import Modal from 'src/components/Modal';
|
import Modal from 'src/components/Modal';
|
||||||
import TableSelector from 'src/components/TableSelector';
|
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 = {
|
type DatasetAddObject = {
|
||||||
id: number;
|
id: number;
|
||||||
|
@ -95,10 +96,11 @@ const DatasetModal: FunctionComponent<DatasetModalProps> = ({
|
||||||
addSuccessToast(t('The dataset has been saved'));
|
addSuccessToast(t('The dataset has been saved'));
|
||||||
onHide();
|
onHide();
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(
|
||||||
addDangerToast(t('Error while saving dataset'));
|
createErrorHandler(errMsg =>
|
||||||
console.error(e);
|
addDangerToast(t('Error while saving dataset: %s', errMsg)),
|
||||||
});
|
),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
|
@ -25,16 +25,13 @@ import React, {
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import rison from 'rison';
|
import rison from 'rison';
|
||||||
|
import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils';
|
||||||
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
||||||
import DeleteModal from 'src/components/DeleteModal';
|
import DeleteModal from 'src/components/DeleteModal';
|
||||||
import ListView, { ListViewProps } from 'src/components/ListView/ListView';
|
import ListView, { ListViewProps } from 'src/components/ListView/ListView';
|
||||||
import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
|
import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
|
||||||
import AvatarIcon from 'src/components/AvatarIcon';
|
import AvatarIcon from 'src/components/AvatarIcon';
|
||||||
import {
|
import { FetchDataConfig, Filters } from 'src/components/ListView/types';
|
||||||
FetchDataConfig,
|
|
||||||
FilterOperatorMap,
|
|
||||||
Filters,
|
|
||||||
} from 'src/components/ListView/types';
|
|
||||||
import withToasts from 'src/messageToasts/enhancers/withToasts';
|
import withToasts from 'src/messageToasts/enhancers/withToasts';
|
||||||
import TooltipWrapper from 'src/components/TooltipWrapper';
|
import TooltipWrapper from 'src/components/TooltipWrapper';
|
||||||
import Icon from 'src/components/Icon';
|
import Icon from 'src/components/Icon';
|
||||||
|
@ -69,29 +66,92 @@ interface DatasetListProps {
|
||||||
addDangerToast: (msg: string) => void;
|
addDangerToast: (msg: string) => void;
|
||||||
addSuccessToast: (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> = ({
|
const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||||
addDangerToast,
|
addDangerToast,
|
||||||
addSuccessToast,
|
addSuccessToast,
|
||||||
}) => {
|
}) => {
|
||||||
const [databases, setDatabases] = useState<{ text: string; value: number }[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [datasetCount, setDatasetCount] = useState(0);
|
const [datasetCount, setDatasetCount] = useState(0);
|
||||||
const [datasetCurrentlyDeleting, setDatasetCurrentlyDeleting] = useState<
|
const [datasetCurrentlyDeleting, setDatasetCurrentlyDeleting] = useState<
|
||||||
(Dataset & { chart_count: number; dashboard_count: number }) | null
|
(Dataset & { chart_count: number; dashboard_count: number }) | null
|
||||||
>(null);
|
>(null);
|
||||||
const [datasets, setDatasets] = useState<any[]>([]);
|
const [datasets, setDatasets] = useState<any[]>([]);
|
||||||
const [currentFilters, setCurrentFilters] = useState<Filters>([]);
|
|
||||||
const [filterOperators, setFilterOperators] = useState<FilterOperatorMap>();
|
|
||||||
const [
|
const [
|
||||||
lastFetchDataConfig,
|
lastFetchDataConfig,
|
||||||
setLastFetchDataConfig,
|
setLastFetchDataConfig,
|
||||||
] = useState<FetchDataConfig | null>(null);
|
] = useState<FetchDataConfig | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentOwners, setCurrentOwners] = useState<
|
|
||||||
{ text: string; value: number }[]
|
|
||||||
>([]);
|
|
||||||
const [permissions, setPermissions] = useState<string[]>([]);
|
const [permissions, setPermissions] = useState<string[]>([]);
|
||||||
|
|
||||||
const [datasetAddModalOpen, setDatasetAddModalOpen] = useState<boolean>(
|
const [datasetAddModalOpen, setDatasetAddModalOpen] = useState<boolean>(
|
||||||
|
@ -99,98 +159,85 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||||
);
|
);
|
||||||
const [bulkSelectEnabled, setBulkSelectEnabled] = useState<boolean>(false);
|
const [bulkSelectEnabled, setBulkSelectEnabled] = useState<boolean>(false);
|
||||||
|
|
||||||
const updateFilters = () => {
|
const filterTypes: Filters = [
|
||||||
const convertFilter = ({
|
{
|
||||||
name: label,
|
Header: t('Owner'),
|
||||||
operator,
|
id: 'owners',
|
||||||
}: {
|
input: 'select',
|
||||||
name: string;
|
operator: 'rel_m_m',
|
||||||
operator: string;
|
unfilteredLabel: 'All',
|
||||||
}) => ({ label, value: operator });
|
fetchSelects: createFetchRelated(
|
||||||
if (filterOperators) {
|
'dataset',
|
||||||
setCurrentFilters([
|
'owners',
|
||||||
{
|
createErrorHandler(errMsg =>
|
||||||
Header: 'Database',
|
t(
|
||||||
id: 'database',
|
'An error occurred while fetching dataset owner values: %s',
|
||||||
input: 'select',
|
errMsg,
|
||||||
operators: filterOperators.database.map(convertFilter),
|
),
|
||||||
selects: databases.map(({ text: label, value }) => ({
|
),
|
||||||
label,
|
),
|
||||||
value,
|
paginate: true,
|
||||||
})),
|
},
|
||||||
},
|
{
|
||||||
{
|
Header: t('Datasource'),
|
||||||
Header: 'Schema',
|
id: 'database',
|
||||||
id: 'schema',
|
input: 'select',
|
||||||
operators: filterOperators.schema.map(convertFilter),
|
operator: 'rel_o_m',
|
||||||
},
|
unfilteredLabel: 'All',
|
||||||
{
|
fetchSelects: createFetchDatabases(
|
||||||
Header: 'Table Name',
|
createErrorHandler(errMsg =>
|
||||||
id: 'table_name',
|
t('An error occurred while fetching datasource values: %s', errMsg),
|
||||||
operators: filterOperators.table_name.map(convertFilter),
|
),
|
||||||
},
|
),
|
||||||
{
|
paginate: true,
|
||||||
Header: 'Owners',
|
},
|
||||||
id: 'owners',
|
{
|
||||||
input: 'select',
|
Header: t('Schema'),
|
||||||
operators: filterOperators.owners.map(convertFilter),
|
id: 'schema',
|
||||||
selects: currentOwners.map(({ text: label, value }) => ({
|
input: 'select',
|
||||||
label,
|
operator: 'eq',
|
||||||
value,
|
unfilteredLabel: 'All',
|
||||||
})),
|
fetchSelects: createFetchSchemas(errMsg =>
|
||||||
},
|
t('An error occurred while fetching schema values: %s', errMsg),
|
||||||
{
|
),
|
||||||
Header: 'SQL Lab View',
|
paginate: true,
|
||||||
id: 'is_sqllab_view',
|
},
|
||||||
input: 'checkbox',
|
{
|
||||||
operators: filterOperators.is_sqllab_view.map(convertFilter),
|
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 fetchDatasetInfo = () => {
|
||||||
|
SupersetClient.get({
|
||||||
|
endpoint: `/api/v1/dataset/_info`,
|
||||||
|
}).then(
|
||||||
|
({ json: infoJson = {} }) => {
|
||||||
|
setPermissions(infoJson.permissions);
|
||||||
|
},
|
||||||
|
createErrorHandler(errMsg =>
|
||||||
|
addDangerToast(t('An error occurred while fetching datasets', errMsg)),
|
||||||
|
),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchDataset = () =>
|
|
||||||
Promise.all([
|
|
||||||
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);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDataset();
|
fetchDatasetInfo();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateFilters();
|
|
||||||
}, [databases, currentOwners, permissions, filterOperators]);
|
|
||||||
|
|
||||||
const hasPerm = (perm: string) => {
|
const hasPerm = (perm: string) => {
|
||||||
if (!permissions.length) {
|
if (!permissions.length) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -220,11 +267,14 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||||
dashboard_count: json.dashboards.count,
|
dashboard_count: json.dashboards.count,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(
|
||||||
addDangerToast(
|
createErrorHandler(errMsg =>
|
||||||
t('An error occurred while fetching dataset related data'),
|
t(
|
||||||
);
|
'An error occurred while fetching dataset related data: %s',
|
||||||
});
|
errMsg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
|
@ -477,15 +527,19 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||||
return SupersetClient.get({
|
return SupersetClient.get({
|
||||||
endpoint: `/api/v1/dataset/?q=${queryParams}`,
|
endpoint: `/api/v1/dataset/?q=${queryParams}`,
|
||||||
})
|
})
|
||||||
.then(({ json }) => {
|
.then(
|
||||||
setLoading(false);
|
({ json }) => {
|
||||||
setDatasets(json.result);
|
setLoading(false);
|
||||||
setDatasetCount(json.count);
|
setDatasets(json.result);
|
||||||
})
|
setDatasetCount(json.count);
|
||||||
.catch(() => {
|
},
|
||||||
addDangerToast(t('An error occurred while fetching datasets'));
|
createErrorHandler(errMsg =>
|
||||||
setLoading(false);
|
addDangerToast(
|
||||||
});
|
t('An error occurred while fetching datasets: %s', errMsg),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
@ -501,10 +555,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||||
setDatasetCurrentlyDeleting(null);
|
setDatasetCurrentlyDeleting(null);
|
||||||
addSuccessToast(t('Deleted: %s', tableName));
|
addSuccessToast(t('Deleted: %s', tableName));
|
||||||
},
|
},
|
||||||
(err: any) => {
|
createErrorHandler(errMsg =>
|
||||||
console.error(err);
|
addDangerToast(
|
||||||
addDangerToast(t('There was an issue deleting %s', tableName));
|
t('There was an issue deleting %s: %s', tableName, errMsg),
|
||||||
},
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -520,10 +575,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||||
}
|
}
|
||||||
addSuccessToast(json.message);
|
addSuccessToast(json.message);
|
||||||
},
|
},
|
||||||
(err: any) => {
|
createErrorHandler(errMsg =>
|
||||||
console.error(err);
|
addDangerToast(
|
||||||
addDangerToast(t('There was an issue deleting the selected datasets'));
|
t('There was an issue deleting the selected datasets: %s', errMsg),
|
||||||
},
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -578,9 +634,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||||
count={datasetCount}
|
count={datasetCount}
|
||||||
pageSize={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
|
filters={filterTypes}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
initialSort={initialSort}
|
initialSort={initialSort}
|
||||||
filters={currentFilters}
|
|
||||||
bulkActions={bulkActions}
|
bulkActions={bulkActions}
|
||||||
bulkSelectEnabled={bulkSelectEnabled}
|
bulkSelectEnabled={bulkSelectEnabled}
|
||||||
disableBulkSelect={() => setBulkSelectEnabled(false)}
|
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 ErrorBoundary from 'src/components/ErrorBoundary';
|
||||||
import Menu from 'src/components/Menu/Menu';
|
import Menu from 'src/components/Menu/Menu';
|
||||||
import FlashProvider from 'src/components/FlashProvider';
|
import FlashProvider from 'src/components/FlashProvider';
|
||||||
import DashboardList from 'src/views/dashboardList/DashboardList';
|
import DashboardList from 'src/views/CRUD/dashboard/DashboardList';
|
||||||
import ChartList from 'src/views/chartList/ChartList';
|
import ChartList from 'src/views/CRUD/chart/ChartList';
|
||||||
import DatasetList from 'src/views/datasetList/DatasetList';
|
import DatasetList from 'src/views/CRUD/dataset/DatasetList';
|
||||||
|
|
||||||
import messageToastReducer from '../messageToasts/reducers';
|
import messageToastReducer from '../messageToasts/reducers';
|
||||||
import { initEnhancer } from '../reduxUtils';
|
import { initEnhancer } from '../reduxUtils';
|
||||||
|
|
|
@ -307,7 +307,6 @@ DEFAULT_FEATURE_FLAGS: Dict[str, bool] = {
|
||||||
"SIP_38_VIZ_REARCHITECTURE": False,
|
"SIP_38_VIZ_REARCHITECTURE": False,
|
||||||
"TAGGING_SYSTEM": False,
|
"TAGGING_SYSTEM": False,
|
||||||
"SQLLAB_BACKEND_PERSISTENCE": False,
|
"SQLLAB_BACKEND_PERSISTENCE": False,
|
||||||
"LIST_VIEWS_SIP34_FILTER_UI": False,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# This is merely a default.
|
# This is merely a default.
|
||||||
|
|
|
@ -16,23 +16,33 @@
|
||||||
# under the License.
|
# under the License.
|
||||||
from typing import Any, Dict, List, Optional
|
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 flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||||
from sqlalchemy.exc import NoSuchTableError, SQLAlchemyError
|
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.decorators import check_datasource_access
|
||||||
from superset.databases.schemas import (
|
from superset.databases.schemas import (
|
||||||
|
DatabaseSchemaResponseSchema,
|
||||||
SelectStarResponseSchema,
|
SelectStarResponseSchema,
|
||||||
TableMetadataResponseSchema,
|
TableMetadataResponseSchema,
|
||||||
)
|
)
|
||||||
from superset.models.core import Database
|
from superset.models.core import Database
|
||||||
from superset.typing import FlaskResponse
|
from superset.typing import FlaskResponse
|
||||||
from superset.utils.core import error_msg_from_exception
|
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.filters import DatabaseFilter
|
||||||
from superset.views.database.validators import sqlalchemy_uri_validator
|
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(
|
def get_foreign_keys_metadata(
|
||||||
database: Database, table_name: str, schema_name: Optional[str]
|
database: Database, table_name: str, schema_name: Optional[str]
|
||||||
|
@ -115,12 +125,13 @@ def get_table_metadata(
|
||||||
class DatabaseRestApi(BaseSupersetModelRestApi):
|
class DatabaseRestApi(BaseSupersetModelRestApi):
|
||||||
datamodel = SQLAInterface(Database)
|
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"
|
class_permission_name = "DatabaseView"
|
||||||
method_permission_name = {
|
method_permission_name = {
|
||||||
"get_list": "list",
|
"get_list": "list",
|
||||||
"table_metadata": "list",
|
"table_metadata": "list",
|
||||||
"select_star": "list",
|
"select_star": "list",
|
||||||
|
"schemas": "list",
|
||||||
}
|
}
|
||||||
resource_name = "database"
|
resource_name = "database"
|
||||||
allow_browser_login = True
|
allow_browser_login = True
|
||||||
|
@ -148,7 +159,11 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
|
||||||
validators_columns = {"sqlalchemy_uri": sqlalchemy_uri_validator}
|
validators_columns = {"sqlalchemy_uri": sqlalchemy_uri_validator}
|
||||||
|
|
||||||
openapi_spec_tag = "Database"
|
openapi_spec_tag = "Database"
|
||||||
|
apispec_parameter_schemas = {
|
||||||
|
"get_schemas_schema": get_schemas_schema,
|
||||||
|
}
|
||||||
openapi_spec_component_schemas = (
|
openapi_spec_component_schemas = (
|
||||||
|
DatabaseSchemaResponseSchema,
|
||||||
TableMetadataResponseSchema,
|
TableMetadataResponseSchema,
|
||||||
SelectStarResponseSchema,
|
SelectStarResponseSchema,
|
||||||
)
|
)
|
||||||
|
@ -265,3 +280,70 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
|
||||||
return self.response(404, message="Table not found on the database")
|
return self.response(404, message="Table not found on the database")
|
||||||
self.incr_stats("success", self.select_star.__name__)
|
self.incr_stats("success", self.select_star.__name__)
|
||||||
return self.response(200, result=result)
|
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):
|
class SelectStarResponseSchema(Schema):
|
||||||
result = fields.String(description="SQL select star")
|
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
|
# isort:skip_file
|
||||||
"""Unit tests for Superset"""
|
"""Unit tests for Superset"""
|
||||||
import json
|
import json
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
import prison
|
import prison
|
||||||
from sqlalchemy.sql import func
|
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/"
|
uri = f"api/v1/database/{example_db.id}/select_star/table_does_not_exist/"
|
||||||
rv = self.client.get(uri)
|
rv = self.client.get(uri)
|
||||||
self.assertEqual(rv.status_code, 404)
|
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