feat: filters for database list view (#10772)

This commit is contained in:
ʈᵃᵢ 2020-09-04 16:23:38 -07:00 committed by GitHub
parent c1ff1c5d70
commit 92f2353f80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 128 additions and 19 deletions

View File

@ -23,6 +23,7 @@ import { QueryParamProvider } from 'use-query-params';
import { supersetTheme, ThemeProvider } from '@superset-ui/style'; import { supersetTheme, ThemeProvider } from '@superset-ui/style';
import Button from 'src/components/Button'; import Button from 'src/components/Button';
import { Empty } from 'src/common/components';
import CardCollection from 'src/components/ListView/CardCollection'; import CardCollection from 'src/components/ListView/CardCollection';
import { CardSortSelect } from 'src/components/ListView/CardSortSelect'; import { CardSortSelect } from 'src/components/ListView/CardSortSelect';
import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
@ -346,6 +347,16 @@ describe('ListView', () => {
); );
}); });
it('renders and empty state when there is no data', () => {
const props = {
...mockedProps,
data: [],
};
const wrapper2 = factory(props);
expect(wrapper2.find(Empty)).toExist();
});
it('renders UI filters', () => { it('renders UI filters', () => {
expect(wrapper.find(ListViewFilters)).toExist(); expect(wrapper.find(ListViewFilters)).toExist();
}); });

View File

@ -26,6 +26,7 @@ import DatabaseList from 'src/views/CRUD/data/database/DatabaseList';
import DatabaseModal from 'src/views/CRUD/data/database/DatabaseModal'; import DatabaseModal from 'src/views/CRUD/data/database/DatabaseModal';
import SubMenu from 'src/components/Menu/SubMenu'; import SubMenu from 'src/components/Menu/SubMenu';
import ListView from 'src/components/ListView'; import ListView from 'src/components/ListView';
import Filters from 'src/components/ListView/Filters';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
@ -116,4 +117,32 @@ describe('DatabaseList', () => {
expect(fetchMock.calls(/database\/0/, 'DELETE')).toHaveLength(1); expect(fetchMock.calls(/database\/0/, 'DELETE')).toHaveLength(1);
}); });
it('filters', async () => {
const filtersWrapper = wrapper.find(Filters);
act(() => {
filtersWrapper
.find('[name="expose_in_sqllab"]')
.first()
.props()
.onSelect(true);
filtersWrapper
.find('[name="allow_run_async"]')
.first()
.props()
.onSelect(false);
filtersWrapper
.find('[name="database_name"]')
.first()
.props()
.onSubmit('fooo');
});
await waitForComponentToPaint(wrapper);
expect(fetchMock.lastCall()[0]).toMatchInlineSnapshot(
`"http://localhost/api/v1/database/?q=(filters:!((col:expose_in_sqllab,opr:eq,value:!t),(col:allow_run_async,opr:eq,value:!f),(col:database_name,opr:ct,value:fooo)),order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`,
);
});
}); });

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import React, { useState } from 'react'; import React, { useState, ReactNode } from 'react';
import { styled, withTheme, SupersetThemeProps } from '@superset-ui/style'; import { styled, withTheme, SupersetThemeProps } from '@superset-ui/style';
import { import {
@ -36,7 +36,7 @@ import {
import { filterSelectStyles } from './utils'; import { filterSelectStyles } from './utils';
interface BaseFilter { interface BaseFilter {
Header: string; Header: ReactNode;
initialValue: any; initialValue: any;
} }
interface SelectFilterProps extends BaseFilter { interface SelectFilterProps extends BaseFilter {
@ -130,7 +130,7 @@ function SelectFilter({
return ( return (
<FilterContainer> <FilterContainer>
<FilterTitle>{Header}</FilterTitle> <FilterTitle>{Header}:</FilterTitle>
{fetchSelects ? ( {fetchSelects ? (
<PaginatedSelect <PaginatedSelect
data-test="filters-select" data-test="filters-select"
@ -168,9 +168,15 @@ const StyledSelectFilter = withTheme(SelectFilter);
interface SearchHeaderProps extends BaseFilter { interface SearchHeaderProps extends BaseFilter {
Header: string; Header: string;
onSubmit: (val: string) => void; onSubmit: (val: string) => void;
name: string;
} }
function SearchFilter({ Header, initialValue, onSubmit }: SearchHeaderProps) { function SearchFilter({
Header,
name,
initialValue,
onSubmit,
}: SearchHeaderProps) {
const [value, setValue] = useState(initialValue || ''); const [value, setValue] = useState(initialValue || '');
const handleSubmit = () => onSubmit(value); const handleSubmit = () => onSubmit(value);
const onClear = () => { const onClear = () => {
@ -183,6 +189,7 @@ function SearchFilter({ Header, initialValue, onSubmit }: SearchHeaderProps) {
<SearchInput <SearchInput
data-test="filters-search" data-test="filters-search"
placeholder={Header} placeholder={Header}
name={name}
value={value} value={value}
onChange={e => { onChange={e => {
setValue(e.currentTarget.value); setValue(e.currentTarget.value);
@ -244,12 +251,13 @@ function UIFilters({
/> />
); );
} }
if (input === 'search') { if (input === 'search' && typeof Header === 'string') {
return ( return (
<SearchFilter <SearchFilter
Header={Header} Header={Header}
initialValue={initialValue} initialValue={initialValue}
key={id} key={id}
name={id}
onSubmit={(value: string) => updateFilterValue(index, value)} onSubmit={(value: string) => updateFilterValue(index, value)}
/> />
); );

View File

@ -16,9 +16,10 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { t } from '@superset-ui/translation';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { t } from '@superset-ui/translation';
import { Alert } from 'react-bootstrap'; import { Alert } from 'react-bootstrap';
import { Empty } from 'src/common/components';
import styled from '@superset-ui/style'; import styled from '@superset-ui/style';
import cx from 'classnames'; import cx from 'classnames';
import Button from 'src/components/Button'; import Button from 'src/components/Button';
@ -140,6 +141,10 @@ const ViewModeContainer = styled.div`
} }
`; `;
const EmptyWrapper = styled.div`
margin: ${({ theme }) => theme.gridUnit * 40}px 0;
`;
const ViewModeToggle = ({ const ViewModeToggle = ({
mode, mode,
setMode, setMode,
@ -348,6 +353,11 @@ function ListView<T extends object = any>({
loading={loading} loading={loading}
/> />
)} )}
{!loading && rows.length === 0 && (
<EmptyWrapper>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</EmptyWrapper>
)}
</div> </div>
</div> </div>

View File

@ -16,6 +16,8 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { ReactNode } from 'react';
export interface SortColumn { export interface SortColumn {
id: string; id: string;
desc?: boolean; desc?: boolean;
@ -36,7 +38,7 @@ export interface CardSortSelectOption {
} }
export interface Filter { export interface Filter {
Header: string; Header: ReactNode;
id: string; id: string;
operators?: SelectOption[]; operators?: SelectOption[];
operator?: operator?:

View File

@ -227,15 +227,21 @@ export function useListViewState({
}, [query]); }, [query]);
const applyFilterValue = (index: number, value: any) => { const applyFilterValue = (index: number, value: any) => {
// skip redunundant updates setInternalFilters(currentInternalFilters => {
if (internalFilters[index].value === value) { // skip redunundant updates
return; if (currentInternalFilters[index].value === value) {
} return currentInternalFilters;
const update = { ...internalFilters[index], value }; }
const updatedFilters = updateInList(internalFilters, index, update); const update = { ...currentInternalFilters[index], value };
setInternalFilters(updatedFilters); const updatedFilters = updateInList(
setAllFilters(convertFilters(updatedFilters)); currentInternalFilters,
gotoPage(0); // clear pagination on filter index,
update,
);
setAllFilters(convertFilters(updatedFilters));
gotoPage(0); // clear pagination on filter
return updatedFilters;
});
}; };
return { return {

View File

@ -20,12 +20,13 @@ import styled from '@superset-ui/style';
import React from 'react'; import React from 'react';
import Icon from 'src/components/Icon'; import Icon from 'src/components/Icon';
interface Props { interface SearchInputProps {
onSubmit: () => void; onSubmit: () => void;
onClear: () => void; onClear: () => void;
value: string; value: string;
onChange: React.EventHandler<React.ChangeEvent<HTMLInputElement>>; onChange: React.EventHandler<React.ChangeEvent<HTMLInputElement>>;
placeholder?: string; placeholder?: string;
name?: string;
} }
const SearchInputWrapper = styled.div` const SearchInputWrapper = styled.div`
@ -68,8 +69,9 @@ export default function SearchInput({
onClear, onClear,
onSubmit, onSubmit,
placeholder = 'Search', placeholder = 'Search',
name,
value, value,
}: Props) { }: SearchInputProps) {
return ( return (
<SearchInputWrapper> <SearchInputWrapper>
<SearchIcon <SearchIcon
@ -89,6 +91,7 @@ export default function SearchInput({
placeholder={placeholder} placeholder={placeholder}
onChange={onChange} onChange={onChange}
value={value} value={value}
name={name}
/> />
{value && ( {value && (
<ClearIcon <ClearIcon

View File

@ -245,7 +245,47 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
[canDelete, canCreate], [canDelete, canCreate],
); );
const filters: Filters = []; const filters: Filters = useMemo(
() => [
{
Header: t('Expose in SQL Lab'),
id: 'expose_in_sqllab',
input: 'select',
operator: 'eq',
unfilteredLabel: 'All',
selects: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
{
Header: (
<TooltipWrapper
label="allow-run-async-filter-header"
tooltip={t('Asynchronous Query Execution')}
placement="top"
>
<span>{t('AQE')}</span>
</TooltipWrapper>
),
id: 'allow_run_async',
input: 'select',
operator: 'eq',
unfilteredLabel: 'All',
selects: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
{
Header: t('Search'),
id: 'database_name',
input: 'search',
operator: 'ct',
},
],
[],
);
return ( return (
<> <>