feat: filters for database list view (#10772)
This commit is contained in:
parent
c1ff1c5d70
commit
92f2353f80
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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?:
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue