diff --git a/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx b/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx index eba56f1f64da..61aca5f8b695 100644 --- a/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx +++ b/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx @@ -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 [], + }, +] +`); }); }); diff --git a/superset-frontend/spec/javascripts/views/chartList/ChartList_spec.jsx b/superset-frontend/spec/javascripts/views/chartList/ChartList_spec.jsx index 60c8ccb1f554..faf0c096d1e1 100644 --- a/superset-frontend/spec/javascripts/views/chartList/ChartList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/chartList/ChartList_spec.jsx @@ -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(, { diff --git a/superset-frontend/src/components/ListView/Filters.tsx b/superset-frontend/src/components/ListView/Filters.tsx index 69d35a1440cc..25b2c5b0bd9d 100644 --- a/superset-frontend/src/components/ListView/Filters.tsx +++ b/superset-frontend/src/components/ListView/Filters.tsx @@ -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 ( {Header}: - + {fetchSelects ? ( + + ) : ( + + )} ); } @@ -134,33 +154,36 @@ function UIFilters({ }: UIFiltersProps) { return ( - {filters.map(({ Header, input, selects, unfilteredLabel }, index) => { - const initialValue = - internalFilters[index] && internalFilters[index].value; - if (input === 'select') { - return ( - updateFilterValue(index, value)} - /> - ); - } - if (input === 'search') { - return ( - updateFilterValue(index, value)} - /> - ); - } - return null; - })} + {filters.map( + ({ Header, input, selects, unfilteredLabel, fetchSelects }, index) => { + const initialValue = + internalFilters[index] && internalFilters[index].value; + if (input === 'select') { + return ( + updateFilterValue(index, value)} + /> + ); + } + if (input === 'search') { + return ( + updateFilterValue(index, value)} + /> + ); + } + return null; + }, + )} ); } diff --git a/superset-frontend/src/components/ListView/types.ts b/superset-frontend/src/components/ListView/types.ts index de85949a2f01..76acae3b7a3e 100644 --- a/superset-frontend/src/components/ListView/types.ts +++ b/superset-frontend/src/components/ListView/types.ts @@ -36,6 +36,8 @@ export interface Filter { input?: 'text' | 'textarea' | 'select' | 'checkbox' | 'search'; unfilteredLabel?: string; selects?: Select[]; + onFilterOpen?: () => void; + fetchSelects?: () => Promise; } 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 { diff --git a/superset-frontend/src/components/StyledSelect.tsx b/superset-frontend/src/components/StyledSelect.tsx index 1a474662e9b3..79d9151fc66d 100644 --- a/superset-frontend/src/components/StyledSelect.tsx +++ b/superset-frontend/src/components/StyledSelect.tsx @@ -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; + } +`; diff --git a/superset-frontend/src/views/chartList/ChartList.tsx b/superset-frontend/src/views/chartList/ChartList.tsx index 942028bda7cf..5b1c5b42c305 100644 --- a/superset-frontend/src/views/chartList/ChartList.tsx +++ b/superset-frontend/src/views/chartList/ChartList.tsx @@ -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 { 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 { 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 { 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 { }, 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 { .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 { operator: string; }) => ({ label, value: operator }); + const owners = await fetchOwners(); this.setState({ filters: [ { @@ -376,7 +468,7 @@ class ChartList extends React.PureComponent { 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 { initialSort={this.initialSort} filters={filters} bulkActions={bulkActions} + useNewUIFilters={this.isNewUIEnabled} /> ); }} diff --git a/superset-frontend/src/views/datasetList/DatasetList.tsx b/superset-frontend/src/views/datasetList/DatasetList.tsx index 9bbc4f053b72..43821b0efdaa 100644 --- a/superset-frontend/src/views/datasetList/DatasetList.tsx +++ b/superset-frontend/src/views/datasetList/DatasetList.tsx @@ -110,7 +110,7 @@ class DatasetList extends React.PureComponent { }, ([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 { }) .catch(() => { this.props.addDangerToast( - t('An error occurred while fetching Datasets'), + t('An error occurred while fetching datasets'), ); }) .finally(() => { diff --git a/superset-frontend/src/welcome/App.jsx b/superset-frontend/src/welcome/App.jsx index 0580ccbb8e42..696f7b2e9b2e 100644 --- a/superset-frontend/src/welcome/App.jsx +++ b/superset-frontend/src/welcome/App.jsx @@ -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')); diff --git a/superset/charts/api.py b/superset/charts/api.py index be4f40747b09..f6e8c351cea9 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -40,6 +40,7 @@ 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, @@ -73,6 +74,8 @@ class ChartRestApi(BaseSupersetModelRestApi): RouteMethod.RELATED, "bulk_delete", # not using RouteMethod since locally defined "data", + "viz_types", + "datasources", } class_permission_name = "SliceModelView" show_columns = [ @@ -102,6 +105,10 @@ class ChartRestApi(BaseSupersetModelRestApi): "viz_type", "params", "cache_timeout", + "owners.id", + "owners.username", + "owners.first_name", + "owners.last_name", ] order_columns = [ "slice_name", @@ -115,6 +122,8 @@ class ChartRestApi(BaseSupersetModelRestApi): "description", "viz_type", "datasource_name", + "datasource_id", + "datasource_type", "owners", ) base_order = ("changed_on", "desc") @@ -493,3 +502,56 @@ def add_apispec_components(self, api_spec: APISpec) -> None: 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) diff --git a/superset/charts/dao.py b/superset/charts/dao.py index 01bfdc6cd640..912f33c8e25f 100644 --- a/superset/charts/dao.py +++ b/superset/charts/dao.py @@ -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 @@ def bulk_delete(models: Optional[List[Slice]], commit: bool = True) -> None: if commit: db.session.rollback() raise ex + + @staticmethod + def fetch_all_datasources() -> List["BaseDatasource"]: + return ConnectorRegistry.get_all_datasources(db.session) diff --git a/superset/views/base_api.py b/superset/views/base_api.py index 3f426178d697..60f9d29527c8 100644 --- a/superset/views/base_api.py +++ b/superset/views/base_api.py @@ -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]] = {} diff --git a/tests/charts/api_tests.py b/tests/charts/api_tests.py index 6a78f30eb601..321b31f88991 100644 --- a/tests/charts/api_tests.py +++ b/tests/charts/api_tests.py @@ -688,3 +688,14 @@ def test_query_exec_not_allowed(self): uri = "api/v1/chart/data" rv = self.client.post(uri, json=query_context) 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)