[charts] adds new filters ui (#9530)

* [charts] adds new filters ui

* move null check to be more visible

* better filter lists and async filter functionality
This commit is contained in:
ʈᵃᵢ 2020-04-21 12:04:11 -07:00 committed by GitHub
parent 9cf33e9f9d
commit 0b999e3b91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 366 additions and 100 deletions

View File

@ -35,6 +35,10 @@ const mockedProps = {
Header: 'ID',
sortable: true,
},
{
accessor: 'age',
Header: 'Age',
},
{
accessor: 'name',
Header: 'Name',
@ -287,6 +291,7 @@ Array [
});
describe('ListView with new UI filters', () => {
const fetchSelectsMock = jest.fn(() => []);
const newFiltersProps = {
...mockedProps,
useNewUIFilters: true,
@ -304,6 +309,13 @@ describe('ListView with new UI filters', () => {
input: 'search',
operator: 'ct',
},
{
Header: 'Age',
id: 'age',
input: 'select',
fetchSelects: fetchSelectsMock,
operator: 'eq',
},
],
};
@ -320,11 +332,15 @@ describe('ListView with new UI filters', () => {
expect(wrapper.find(ListViewFilters)).toHaveLength(1);
});
it('fetched selects if function is provided', () => {
expect(fetchSelectsMock).toHaveBeenCalled();
});
it('calls fetchData on filter', () => {
act(() => {
wrapper
.find('[data-test="filters-select"]')
.last()
.first()
.props()
.onChange({ value: 'bar' });
});
@ -332,7 +348,7 @@ describe('ListView with new UI filters', () => {
act(() => {
wrapper
.find('[data-test="filters-search"]')
.last()
.first()
.props()
.onChange({ currentTarget: { value: 'something' } });
});
@ -348,42 +364,42 @@ describe('ListView with new UI filters', () => {
});
expect(newFiltersProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"filters": Array [
Object {
"id": "id",
"operator": "eq",
"value": "bar",
},
],
"pageIndex": 0,
"pageSize": 1,
"sortBy": Array [],
},
]
`);
Array [
Object {
"filters": Array [
Object {
"id": "id",
"operator": "eq",
"value": "bar",
},
],
"pageIndex": 0,
"pageSize": 1,
"sortBy": Array [],
},
]
`);
expect(newFiltersProps.fetchData.mock.calls[1]).toMatchInlineSnapshot(`
Array [
Object {
"filters": Array [
Object {
"id": "id",
"operator": "eq",
"value": "bar",
},
Object {
"id": "name",
"operator": "ct",
"value": "something",
},
],
"pageIndex": 0,
"pageSize": 1,
"sortBy": Array [],
},
]
`);
Array [
Object {
"filters": Array [
Object {
"id": "id",
"operator": "eq",
"value": "bar",
},
Object {
"id": "name",
"operator": "ct",
"value": "something",
},
],
"pageIndex": 0,
"pageSize": 1,
"sortBy": Array [],
},
]
`);
});
});

View File

@ -32,6 +32,8 @@ const store = mockStore({});
const chartsInfoEndpoint = 'glob:*/api/v1/chart/_info*';
const chartssOwnersEndpoint = 'glob:*/api/v1/chart/related/owners*';
const chartsEndpoint = 'glob:*/api/v1/chart/?*';
const chartsVizTypesEndpoint = 'glob:*/api/v1/chart/viz_types';
const chartsDtasourcesEndpoint = 'glob:*/api/v1/chart/datasources';
const mockCharts = [...new Array(3)].map((_, i) => ({
changed_on: new Date().toISOString(),
@ -40,6 +42,7 @@ const mockCharts = [...new Array(3)].map((_, i) => ({
slice_name: `cool chart ${i}`,
url: 'url',
viz_type: 'bar',
datasource_name: `ds${i}`,
}));
fetchMock.get(chartsInfoEndpoint, {
@ -60,6 +63,16 @@ fetchMock.get(chartsEndpoint, {
chart_count: 3,
});
fetchMock.get(chartsVizTypesEndpoint, {
result: [],
count: 0,
});
fetchMock.get(chartsDtasourcesEndpoint, {
result: [],
count: 0,
});
describe('ChartList', () => {
const mockedProps = {};
const wrapper = mount(<ChartList {...mockedProps} />, {

View File

@ -20,7 +20,7 @@ import React, { useState } from 'react';
import styled from '@emotion/styled';
import { withTheme } from 'emotion-theming';
import StyledSelect from 'src/components/StyledSelect';
import StyledSelect, { AsyncStyledSelect } from 'src/components/StyledSelect';
import SearchInput from 'src/components/SearchInput';
import { Filter, Filters, FilterValue, InternalFilter } from './types';
@ -32,6 +32,7 @@ interface SelectFilterProps extends BaseFilter {
onSelect: (selected: any) => any;
selects: Filter['selects'];
emptyLabel?: string;
fetchSelects?: Filter['fetchSelects'];
}
const FilterContainer = styled.div`
@ -51,11 +52,13 @@ function SelectFilter({
emptyLabel = 'None',
initialValue,
onSelect,
fetchSelects,
}: SelectFilterProps) {
const clearFilterSelect = {
label: emptyLabel,
value: CLEAR_SELECT_FILTER_VALUE,
};
const options = React.useMemo(() => [clearFilterSelect, ...selects], [
emptyLabel,
selects,
@ -73,17 +76,34 @@ function SelectFilter({
selected.value === CLEAR_SELECT_FILTER_VALUE ? undefined : selected.value,
);
};
const fetchAndFormatSelects = async () => {
if (!fetchSelects) return { options: [clearFilterSelect] };
const selectValues = await fetchSelects();
return { options: [clearFilterSelect, ...selectValues] };
};
return (
<FilterContainer>
<Title>{Header}:</Title>
<StyledSelect
data-test="filters-select"
value={value}
options={options}
onChange={onChange}
clearable={false}
/>
{fetchSelects ? (
<AsyncStyledSelect
data-test="filters-select"
value={value}
onChange={onChange}
loadOptions={fetchAndFormatSelects}
placeholder={initialValue || emptyLabel}
loadingPlaceholder="Loading..."
clearable={false}
/>
) : (
<StyledSelect
data-test="filters-select"
value={value}
options={options}
onChange={onChange}
clearable={false}
/>
)}
</FilterContainer>
);
}
@ -134,33 +154,36 @@ function UIFilters({
}: UIFiltersProps) {
return (
<FilterWrapper>
{filters.map(({ Header, input, selects, unfilteredLabel }, index) => {
const initialValue =
internalFilters[index] && internalFilters[index].value;
if (input === 'select') {
return (
<SelectFilter
key={Header}
Header={Header}
selects={selects}
emptyLabel={unfilteredLabel}
initialValue={initialValue}
onSelect={(value: any) => updateFilterValue(index, value)}
/>
);
}
if (input === 'search') {
return (
<SearchFilter
key={Header}
Header={Header}
initialValue={initialValue}
onSubmit={(value: string) => updateFilterValue(index, value)}
/>
);
}
return null;
})}
{filters.map(
({ Header, input, selects, unfilteredLabel, fetchSelects }, index) => {
const initialValue =
internalFilters[index] && internalFilters[index].value;
if (input === 'select') {
return (
<SelectFilter
key={Header}
Header={Header}
selects={selects}
emptyLabel={unfilteredLabel}
initialValue={initialValue}
fetchSelects={fetchSelects}
onSelect={(value: any) => updateFilterValue(index, value)}
/>
);
}
if (input === 'search') {
return (
<SearchFilter
key={Header}
Header={Header}
initialValue={initialValue}
onSubmit={(value: string) => updateFilterValue(index, value)}
/>
);
}
return null;
},
)}
</FilterWrapper>
);
}

View File

@ -36,6 +36,8 @@ export interface Filter {
input?: 'text' | 'textarea' | 'select' | 'checkbox' | 'search';
unfilteredLabel?: string;
selects?: Select[];
onFilterOpen?: () => void;
fetchSelects?: () => Promise<Select[]>;
}
export type Filters = Filter[];
@ -43,7 +45,13 @@ export type Filters = Filter[];
export interface FilterValue {
id: string;
operator?: string;
value: string | boolean | number | null | undefined;
value:
| string
| boolean
| number
| null
| undefined
| { datasource_id: number; datasource_type: string };
}
export interface FetchDataConfig {

View File

@ -18,7 +18,7 @@
*/
import styled from '@emotion/styled';
// @ts-ignore
import Select from 'react-select';
import Select, { Async } from 'react-select';
export default styled(Select)`
display: inline;
@ -46,3 +46,30 @@ export default styled(Select)`
border-bottom-left-radius: 0;
}
`;
export const AsyncStyledSelect = styled(Async)`
display: inline;
&.is-focused:not(.is-open) > .Select-control {
border: none;
box-shadow: none;
}
.Select-control {
display: inline-table;
border: none;
width: 100px;
&:focus,
&:hover {
border: none;
box-shadow: none;
}
.Select-arrow-zone {
padding-left: 10px;
}
}
.Select-menu-outer {
margin-top: 0;
border-bottom-left-radius: 0;
border-bottom-left-radius: 0;
}
`;

View File

@ -18,6 +18,7 @@
*/
import { SupersetClient } from '@superset-ui/connection';
import { t } from '@superset-ui/translation';
import { getChartMetadataRegistry } from '@superset-ui/chart';
import moment from 'moment';
import PropTypes from 'prop-types';
import React from 'react';
@ -33,6 +34,7 @@ import {
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;
@ -47,7 +49,6 @@ interface State {
loading: boolean;
filterOperators: FilterOperatorMap;
filters: Filters;
owners: Array<{ text: string; value: number }>;
lastFetchDataConfig: FetchDataConfig | null;
permissions: string[];
// for now we need to use the Slice type defined in PropertiesModal.
@ -67,32 +68,31 @@ class ChartList extends React.PureComponent<Props, State> {
filters: [],
lastFetchDataConfig: null,
loading: false,
owners: [],
permissions: [],
sliceCurrentlyEditing: null,
};
componentDidMount() {
Promise.all([
SupersetClient.get({
endpoint: `/api/v1/chart/_info`,
}),
SupersetClient.get({
endpoint: `/api/v1/chart/related/owners`,
}),
]).then(
([{ json: infoJson = {} }, { json: ownersJson = {} }]) => {
SupersetClient.get({
endpoint: `/api/v1/chart/_info`,
}).then(
({ json: infoJson = {} }) => {
this.setState(
{
filterOperators: infoJson.filters,
owners: ownersJson.result,
permissions: infoJson.permissions,
},
this.updateFilters,
);
},
([e1, e2]) => {
this.props.addDangerToast(t('An error occurred while fetching Charts'));
this.props.addDangerToast(
t(
'An error occurred while fetching charts: %s, %s',
e1.message,
e2.message,
),
);
if (e1) {
console.error(e1);
}
@ -111,6 +111,10 @@ class ChartList extends React.PureComponent<Props, State> {
return this.hasPerm('can_delete');
}
get isNewUIEnabled() {
return isFeatureEnabled(FeatureFlag.LIST_VIEWS_NEW_UI);
}
initialSort = [{ id: 'changed_on', desc: true }];
columns = [
@ -175,6 +179,10 @@ class ChartList extends React.PureComponent<Props, State> {
accessor: 'owners',
hidden: true,
},
{
accessor: 'datasource',
hidden: true,
},
{
Cell: ({ row: { state, original } }: any) => {
const handleDelete = () => this.handleChartDelete(original);
@ -311,11 +319,27 @@ class ChartList extends React.PureComponent<Props, State> {
},
loading: true,
});
const filterExps = filters.map(({ id: col, operator: opr, value }) => ({
col,
opr,
value,
}));
const filterExps = filters
.map(({ id: col, operator: opr, value }) => ({
col,
opr,
value,
}))
.reduce((acc, fltr) => {
if (
fltr.col === 'datasource' &&
fltr.value &&
typeof fltr.value === 'object'
) {
const { datasource_id: dsId, datasource_type: dsType } = fltr.value;
return [
...acc,
{ ...fltr, col: 'datasource_id', value: dsId },
{ ...fltr, col: 'datasource_type', value: dsType },
];
}
return [...acc, fltr];
}, []);
const queryParams = JSON.stringify({
order_column: sortBy[0].id,
@ -331,16 +355,83 @@ class ChartList extends React.PureComponent<Props, State> {
.then(({ json = {} }) => {
this.setState({ charts: json.result, chartCount: json.count });
})
.catch(() => {
this.props.addDangerToast(t('An error occurred while fetching Charts'));
.catch(e => {
this.props.addDangerToast(
t('An error occurred while fetching charts: %s', e.message),
);
})
.finally(() => {
this.setState({ loading: false });
});
};
updateFilters = () => {
const { filterOperators, owners } = this.state;
createFetchResource = (
resource: string,
postProcess?: (value: []) => any[],
) => async () => {
try {
const { json = {} } = await SupersetClient.get({
endpoint: resource,
});
return postProcess ? postProcess(json?.result) : json?.result;
} catch (e) {
this.props.addDangerToast(
t('An error occurred while fetching chart filters: %s', e.message),
);
}
return [];
};
convertOwners = (owners: any[]) =>
owners.map(({ text: label, value }) => ({ label, value }));
updateFilters = async () => {
const { filterOperators } = this.state;
const fetchOwners = this.createFetchResource(
'/api/v1/chart/related/owners',
this.convertOwners,
);
if (this.isNewUIEnabled) {
this.setState({
filters: [
{
Header: 'Owner',
id: 'owners',
input: 'select',
operator: 'rel_m_m',
unfilteredLabel: 'All',
fetchSelects: fetchOwners,
},
{
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.createFetchResource('/api/v1/chart/datasources'),
},
{
Header: 'Search',
id: 'slice_name',
input: 'search',
operator: 'name_or_description',
},
],
});
return;
}
const convertFilter = ({
name: label,
operator,
@ -349,6 +440,7 @@ class ChartList extends React.PureComponent<Props, State> {
operator: string;
}) => ({ label, value: operator });
const owners = await fetchOwners();
this.setState({
filters: [
{
@ -376,7 +468,7 @@ class ChartList extends React.PureComponent<Props, State> {
id: 'owners',
input: 'select',
operators: filterOperators.owners.map(convertFilter),
selects: owners.map(({ text: label, value }) => ({ label, value })),
selects: owners,
},
],
});
@ -435,6 +527,7 @@ class ChartList extends React.PureComponent<Props, State> {
initialSort={this.initialSort}
filters={filters}
bulkActions={bulkActions}
useNewUIFilters={this.isNewUIEnabled}
/>
);
}}

View File

@ -110,7 +110,7 @@ class DatasetList extends React.PureComponent<Props, State> {
},
([e1, e2]) => {
this.props.addDangerToast(
t('An error occurred while fetching Datasets'),
t('An error occurred while fetching datasets'),
);
if (e1) {
console.error(e1);
@ -326,7 +326,7 @@ class DatasetList extends React.PureComponent<Props, State> {
})
.catch(() => {
this.props.addDangerToast(
t('An error occurred while fetching Datasets'),
t('An error occurred while fetching datasets'),
);
})
.finally(() => {

View File

@ -34,10 +34,12 @@ import DatasetList from 'src/views/datasetList/DatasetList';
import messageToastReducer from '../messageToasts/reducers';
import { initEnhancer } from '../reduxUtils';
import setupApp from '../setup/setupApp';
import setupPlugins from '../setup/setupPlugins';
import Welcome from './Welcome';
import ToastPresenter from '../messageToasts/containers/ToastPresenter';
setupApp();
setupPlugins();
const container = document.getElementById('app');
const bootstrap = JSON.parse(container.getAttribute('data-bootstrap'));

View File

@ -40,6 +40,7 @@ from superset.charts.commands.exceptions import (
ChartUpdateFailedError,
)
from superset.charts.commands.update import UpdateChartCommand
from superset.charts.dao import ChartDAO
from superset.charts.filters import ChartFilter, ChartNameOrDescriptionFilter
from superset.charts.schemas import (
CHART_DATA_SCHEMAS,
@ -77,6 +78,8 @@ class ChartRestApi(BaseSupersetModelRestApi):
RouteMethod.RELATED,
"bulk_delete", # not using RouteMethod since locally defined
"data",
"viz_types",
"datasources",
}
class_permission_name = "SliceModelView"
show_columns = [
@ -106,6 +109,10 @@ class ChartRestApi(BaseSupersetModelRestApi):
"viz_type",
"params",
"cache_timeout",
"owners.id",
"owners.username",
"owners.first_name",
"owners.last_name",
]
order_columns = [
"slice_name",
@ -119,6 +126,8 @@ class ChartRestApi(BaseSupersetModelRestApi):
"description",
"viz_type",
"datasource_name",
"datasource_id",
"datasource_type",
"owners",
)
base_order = ("changed_on", "desc")
@ -503,3 +512,56 @@ class ChartRestApi(BaseSupersetModelRestApi):
chart_type.__name__, schema=chart_type,
)
super().add_apispec_components(api_spec)
@expose("/datasources", methods=["GET"])
@protect()
@safe
def datasources(self) -> Response:
"""Get available datasources
---
get:
responses:
200:
description: charts unique datasource data
content:
application/json:
schema:
type: object
properties:
count:
type: integer
result:
type: object
properties:
label:
type: string
value:
type: object
properties:
database_id:
type: integer
database_type:
type: string
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'
"""
datasources = ChartDAO.fetch_all_datasources()
if not datasources:
return self.response(200, count=0, result=[])
result = [
{
"label": str(ds),
"value": {"datasource_id": ds.id, "datasource_type": ds.type},
}
for ds in datasources
]
return self.response(200, count=len(result), result=result)

View File

@ -15,15 +15,20 @@
# specific language governing permissions and limitations
# under the License.
import logging
from typing import List, Optional
from typing import List, Optional, TYPE_CHECKING
from sqlalchemy.exc import SQLAlchemyError
from superset.charts.filters import ChartFilter
from superset.connectors.connector_registry import ConnectorRegistry
from superset.dao.base import BaseDAO
from superset.extensions import db
from superset.models.slice import Slice
if TYPE_CHECKING:
# pylint: disable=unused-import
from superset.connectors.base.models import BaseDatasource
logger = logging.getLogger(__name__)
@ -51,3 +56,7 @@ class ChartDAO(BaseDAO):
if commit:
db.session.rollback()
raise ex
@staticmethod
def fetch_all_datasources() -> List["BaseDatasource"]:
return ConnectorRegistry.get_all_datasources(db.session)

View File

@ -78,6 +78,8 @@ class BaseSupersetModelRestApi(ModelRestApi):
"thumbnail": "list",
"refresh": "edit",
"data": "list",
"viz_types": "list",
"datasources": "list",
}
order_rel_fields: Dict[str, Tuple[str, str]] = {}

View File

@ -689,3 +689,14 @@ class ChartApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
uri = "api/v1/chart/data"
rv = self.post_assert_metric(uri, query_context, "data")
self.assertEqual(rv.status_code, 401)
def test_datasources(self):
"""
Chart API: Test get datasources
"""
self.login(username="admin")
uri = "api/v1/chart/datasources"
rv = self.client.get(uri)
self.assertEqual(rv.status_code, 200)
data = json.loads(rv.data.decode("utf-8"))
self.assertEqual(data["count"], 6)