refactor: Changes the list views to use the new Select component (#16393)

* chore: Change the list views to use the new Select component

* Fix Cypress tests

* Enables search for all controls

* Adjusts controls width

* Removes 'Me' and keeps the logged user on top

* Fixes tests

* Uses the borderless version for the filters

* Fixes the tests

* Reverts the Select theme to the default

* Rebases and fixes js error

* Fixes failing test

* Removes unused withTheme
This commit is contained in:
Michael S. Molina 2021-09-22 07:44:18 -03:00 committed by GitHub
parent 596e1cdf9b
commit b6d78bf4f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 510 additions and 835 deletions

View File

@ -27,44 +27,34 @@ describe('chart card view filters', () => {
it('should filter by owners correctly', () => { it('should filter by owners correctly', () => {
// filter by owners // filter by owners
cy.get('.Select__control').first().click(); cy.get('[data-test="filters-select"]').first().click();
cy.get('.Select__menu').contains('alpha user').click(); cy.get('.rc-virtual-list').contains('alpha user').click();
cy.get('[data-test="styled-card"]').should('not.exist'); cy.get('[data-test="styled-card"]').should('not.exist');
cy.get('.Select__control').first().click(); cy.get('[data-test="filters-select"]').first().click();
cy.get('.Select__menu').contains('gamma user').click(); cy.get('.rc-virtual-list').contains('gamma user').click();
cy.get('[data-test="styled-card"]').should('not.exist'); cy.get('[data-test="styled-card"]').should('not.exist');
}); });
it('should filter by me correctly', () => {
// filter by me
cy.get('.Select__control').first().click();
cy.get('.Select__menu').contains('me').click();
cy.get('[data-test="styled-card"]').its('length').should('be.gt', 0);
cy.get('.Select__control').eq(1).click();
cy.get('.Select__menu').contains('me').click();
cy.get('[data-test="styled-card"]').its('length').should('be.gt', 0);
});
it('should filter by created by correctly', () => { it('should filter by created by correctly', () => {
// filter by created by // filter by created by
cy.get('.Select__control').eq(1).click(); cy.get('[data-test="filters-select"]').eq(1).click();
cy.get('.Select__menu').contains('alpha user').click(); cy.get('.rc-virtual-list').contains('alpha user').click();
cy.get('.ant-card').should('not.exist'); cy.get('.ant-card').should('not.exist');
cy.get('.Select__control').eq(1).click(); cy.get('[data-test="filters-select"]').eq(1).click();
cy.get('.Select__menu').contains('gamma user').click(); cy.get('.rc-virtual-list').contains('gamma user').click();
cy.get('[data-test="styled-card"]').should('not.exist'); cy.get('[data-test="styled-card"]').should('not.exist');
}); });
xit('should filter by viz type correctly', () => { xit('should filter by viz type correctly', () => {
// filter by viz type // filter by viz type
cy.get('.Select__control').eq(2).click(); cy.get('[data-test="filters-select"]').eq(2).click();
cy.get('.Select__menu').contains('area').click({ timeout: 5000 }); cy.get('.rc-virtual-list').contains('area').click({ timeout: 5000 });
cy.get('[data-test="styled-card"]').its('length').should('be.gt', 0); cy.get('[data-test="styled-card"]').its('length').should('be.gt', 0);
cy.get('[data-test="styled-card"]') cy.get('[data-test="styled-card"]')
.contains("World's Pop Growth") .contains("World's Pop Growth")
.should('be.visible'); .should('be.visible');
cy.get('.Select__control').eq(2).click(); cy.get('[data-test="filters-select"]').eq(2).click();
cy.get('.Select__control').eq(2).type('world_map{enter}'); cy.get('[data-test="filters-select"]').eq(2).type('world_map{enter}');
cy.get('[data-test="styled-card"]').should('have.length', 1); cy.get('[data-test="styled-card"]').should('have.length', 1);
cy.get('[data-test="styled-card"]') cy.get('[data-test="styled-card"]')
.contains('% Rural') .contains('% Rural')
@ -73,14 +63,16 @@ describe('chart card view filters', () => {
it('should filter by datasource correctly', () => { it('should filter by datasource correctly', () => {
// filter by datasource // filter by datasource
cy.get('.Select__control').eq(3).click(); cy.get('[data-test="filters-select"]').eq(3).click();
cy.get('.Select__menu').contains('unicode_test').click(); cy.get('.rc-virtual-list').contains('unicode_test').click();
cy.get('[data-test="styled-card"]').should('have.length', 1); cy.get('[data-test="styled-card"]').should('have.length', 1);
cy.get('[data-test="styled-card"]') cy.get('[data-test="styled-card"]')
.contains('Unicode Cloud') .contains('Unicode Cloud')
.should('be.visible'); .should('be.visible');
cy.get('.Select__control').eq(2).click(); cy.get('[data-test="filters-select"]').eq(2).click();
cy.get('.Select__control').eq(2).type('energy_usage{enter}{enter}'); cy.get('[data-test="filters-select"]')
.eq(2)
.type('energy_usage{enter}{enter}');
cy.get('[data-test="styled-card"]').its('length').should('be.gt', 0); cy.get('[data-test="styled-card"]').its('length').should('be.gt', 0);
}); });
}); });
@ -94,57 +86,49 @@ describe('chart list view filters', () => {
it('should filter by owners correctly', () => { it('should filter by owners correctly', () => {
// filter by owners // filter by owners
cy.get('.Select__control').first().click(); cy.get('[data-test="filters-select"]').first().click();
cy.get('.Select__menu').contains('alpha user').click(); cy.get('.rc-virtual-list').contains('alpha user').click();
cy.get('[data-test="table-row"]').should('not.exist'); cy.get('[data-test="table-row"]').should('not.exist');
cy.get('.Select__control').first().click(); cy.get('[data-test="filters-select"]').first().click();
cy.get('.Select__menu').contains('gamma user').click(); cy.get('.rc-virtual-list').contains('gamma user').click();
cy.get('[data-test="table-row"]').should('not.exist'); cy.get('[data-test="table-row"]').should('not.exist');
}); });
it('should filter by me correctly', () => {
// filter by me
cy.get('.Select__control').first().click();
cy.get('.Select__menu').contains('me').click();
cy.get('[data-test="table-row"]').its('length').should('be.gt', 0);
cy.get('.Select__control').eq(1).click();
cy.get('.Select__menu').contains('me').click();
cy.get('[data-test="table-row"]').its('length').should('be.gt', 0);
});
it('should filter by created by correctly', () => { it('should filter by created by correctly', () => {
// filter by created by // filter by created by
cy.get('.Select__control').eq(1).click(); cy.get('[data-test="filters-select"]').eq(1).click();
cy.get('.Select__menu').contains('alpha user').click(); cy.get('.rc-virtual-list').contains('alpha user').click();
cy.get('[data-test="table-row"]').should('not.exist'); cy.get('[data-test="table-row"]').should('not.exist');
cy.get('.Select__control').eq(1).click(); cy.get('[data-test="filters-select"]').eq(1).click();
cy.get('.Select__menu').contains('gamma user').click(); cy.get('.rc-virtual-list').contains('gamma user').click();
cy.get('[data-test="table-row"]').should('not.exist'); cy.get('[data-test="table-row"]').should('not.exist');
}); });
// this is flaky, but seems to fail along with the card view test of the same name // this is flaky, but seems to fail along with the card view test of the same name
xit('should filter by viz type correctly', () => { xit('should filter by viz type correctly', () => {
// filter by viz type // filter by viz type
cy.get('.Select__control').eq(2).click(); cy.get('[data-test="filters-select"]').eq(2).click();
cy.get('.Select__menu').contains('area').click({ timeout: 5000 }); cy.get('.rc-virtual-list').contains('area').click({ timeout: 5000 });
cy.get('[data-test="table-row"]').its('length').should('be.gt', 0); cy.get('[data-test="table-row"]').its('length').should('be.gt', 0);
cy.get('[data-test="table-row"]') cy.get('[data-test="table-row"]')
.contains("World's Pop Growth") .contains("World's Pop Growth")
.should('exist'); .should('exist');
cy.get('.Select__control').eq(2).click(); cy.get('[data-test="filters-select"]').eq(2).click();
cy.get('.Select__control').eq(2).type('world_map{enter}'); cy.get('[data-test="filters-select"]').eq(2).type('world_map{enter}');
cy.get('[data-test="table-row"]').should('have.length', 1); cy.get('[data-test="table-row"]').should('have.length', 1);
cy.get('[data-test="table-row"]').contains('% Rural').should('exist'); cy.get('[data-test="table-row"]').contains('% Rural').should('exist');
}); });
it('should filter by datasource correctly', () => { it('should filter by datasource correctly', () => {
// filter by datasource // filter by datasource
cy.get('.Select__control').eq(3).click(); cy.get('[data-test="filters-select"]').eq(3).click();
cy.get('.Select__menu').contains('unicode_test').click(); cy.get('.rc-virtual-list').contains('unicode_test').click();
cy.get('[data-test="table-row"]').should('have.length', 1); cy.get('[data-test="table-row"]').should('have.length', 1);
cy.get('[data-test="table-row"]').contains('Unicode Cloud').should('exist'); cy.get('[data-test="table-row"]').contains('Unicode Cloud').should('exist');
cy.get('.Select__control').eq(3).click(); cy.get('[data-test="filters-select"]').eq(3).click();
cy.get('.Select__control').eq(3).type('energy_usage{enter}{enter}'); cy.get('[data-test="filters-select"]')
.eq(3)
.type('energy_usage{enter}{enter}');
cy.get('[data-test="table-row"]').its('length').should('be.gt', 0); cy.get('[data-test="table-row"]').its('length').should('be.gt', 0);
}); });
}); });

View File

@ -27,44 +27,34 @@ describe('dashboard filters card view', () => {
it('should filter by owners correctly', () => { it('should filter by owners correctly', () => {
// filter by owners // filter by owners
cy.get('.Select__control').first().click(); cy.get('[data-test="filters-select"]').first().click();
cy.get('.Select__menu').contains('alpha user').click(); cy.get('.rc-virtual-list').contains('alpha user').click();
cy.get('[data-test="styled-card"]').should('not.exist'); cy.get('[data-test="styled-card"]').should('not.exist');
cy.get('.Select__control').first().click(); cy.get('[data-test="filters-select"]').first().click();
cy.get('.Select__menu').contains('gamma user').click(); cy.get('.rc-virtual-list').contains('gamma user').click();
cy.get('[data-test="styled-card"]').should('not.exist'); cy.get('[data-test="styled-card"]').should('not.exist');
}); });
it('should filter by me correctly', () => {
// filter by me
cy.get('.Select__control').first().click();
cy.get('.Select__menu').contains('me').click();
cy.get('[data-test="styled-card"]').its('length').should('be.gt', 0);
cy.get('.Select__control').eq(1).click();
cy.get('.Select__menu').contains('me').click();
cy.get('[data-test="styled-card"]').its('length').should('be.gt', 0);
});
it('should filter by created by correctly', () => { it('should filter by created by correctly', () => {
// filter by created by // filter by created by
cy.get('.Select__control').eq(1).click(); cy.get('[data-test="filters-select"]').eq(1).click();
cy.get('.Select__menu').contains('alpha user').click(); cy.get('.rc-virtual-list').contains('alpha user').click();
cy.get('.ant-card').should('not.exist'); cy.get('.ant-card').should('not.exist');
cy.get('.Select__control').eq(1).click(); cy.get('[data-test="filters-select"]').eq(1).click();
cy.get('.Select__menu').contains('gamma user').click(); cy.get('.rc-virtual-list').contains('gamma user').click();
cy.get('.ant-card').should('not.exist'); cy.get('.ant-card').should('not.exist');
}); });
it('should filter by published correctly', () => { it('should filter by published correctly', () => {
// filter by published // filter by published
cy.get('.Select__control').eq(2).click(); cy.get('[data-test="filters-select"]').eq(2).click();
cy.get('.Select__menu').contains('Published').click({ timeout: 5000 }); cy.get('.rc-virtual-list').contains('Published').click({ timeout: 5000 });
cy.get('[data-test="styled-card"]').should('have.length', 3); cy.get('[data-test="styled-card"]').should('have.length', 3);
cy.get('[data-test="styled-card"]') cy.get('[data-test="styled-card"]')
.contains('USA Births Names') .contains('USA Births Names')
.should('be.visible'); .should('be.visible');
cy.get('.Select__control').eq(1).click(); cy.get('[data-test="filters-select"]').eq(1).click();
cy.get('.Select__control').eq(1).type('unpub{enter}'); cy.get('[data-test="filters-select"]').eq(1).type('unpub{enter}');
cy.get('[data-test="styled-card"]').should('have.length', 3); cy.get('[data-test="styled-card"]').should('have.length', 3);
}); });
}); });
@ -78,44 +68,34 @@ describe('dashboard filters list view', () => {
it('should filter by owners correctly', () => { it('should filter by owners correctly', () => {
// filter by owners // filter by owners
cy.get('.Select__control').first().click(); cy.get('[data-test="filters-select"]').first().click();
cy.get('.Select__menu').contains('alpha user').click(); cy.get('.rc-virtual-list').contains('alpha user').click();
cy.get('[data-test="table-row"]').should('not.exist'); cy.get('[data-test="table-row"]').should('not.exist');
cy.get('.Select__control').first().click(); cy.get('[data-test="filters-select"]').first().click();
cy.get('.Select__menu').contains('gamma user').click(); cy.get('.rc-virtual-list').contains('gamma user').click();
cy.get('[data-test="table-row"]').should('not.exist'); cy.get('[data-test="table-row"]').should('not.exist');
}); });
it('should filter by me correctly', () => {
// filter by me
cy.get('.Select__control').first().click();
cy.get('.Select__menu').contains('me').click();
cy.get('[data-test="table-row"]').its('length').should('be.gt', 0);
cy.get('.Select__control').eq(1).click();
cy.get('.Select__menu').contains('me').click();
cy.get('[data-test="table-row"]').its('length').should('be.gt', 0);
});
it('should filter by created by correctly', () => { it('should filter by created by correctly', () => {
// filter by created by // filter by created by
cy.get('.Select__control').eq(1).click(); cy.get('[data-test="filters-select"]').eq(1).click();
cy.get('.Select__menu').contains('alpha user').click(); cy.get('.rc-virtual-list').contains('alpha user').click();
cy.get('[data-test="table-row"]').should('not.exist'); cy.get('[data-test="table-row"]').should('not.exist');
cy.get('.Select__control').eq(1).click(); cy.get('[data-test="filters-select"]').eq(1).click();
cy.get('.Select__menu').contains('gamma user').click(); cy.get('.rc-virtual-list').contains('gamma user').click();
cy.get('[data-test="table-row"]').should('not.exist'); cy.get('[data-test="table-row"]').should('not.exist');
}); });
it('should filter by published correctly', () => { it('should filter by published correctly', () => {
// filter by published // filter by published
cy.get('.Select__control').eq(2).click(); cy.get('[data-test="filters-select"]').eq(2).click();
cy.get('.Select__menu').contains('Published').click(); cy.get('.rc-virtual-list').contains('Published').click();
cy.get('[data-test="table-row"]').should('have.length', 3); cy.get('[data-test="table-row"]').should('have.length', 3);
cy.get('[data-test="table-row"]') cy.get('[data-test="table-row"]')
.contains('USA Births Names') .contains('USA Births Names')
.should('be.visible'); .should('be.visible');
cy.get('.Select__control').eq(2).click(); cy.get('[data-test="filters-select"]').eq(2).click();
cy.get('.Select__control').eq(2).type('unpub{enter}'); cy.get('[data-test="filters-select"]').eq(2).type('unpub{enter}');
cy.get('[data-test="table-row"]').should('have.length', 3); cy.get('[data-test="table-row"]').should('have.length', 3);
}); });
}); });

View File

@ -17,6 +17,12 @@
* under the License. * under the License.
*/ */
import { DatePicker as AntdDatePicker } from 'antd'; import { DatePicker as AntdDatePicker } from 'antd';
import { styled } from '@superset-ui/core';
const AntdRangePicker = AntdDatePicker.RangePicker;
export const RangePicker = styled(AntdRangePicker)`
border-radius: ${({ theme }) => theme.gridUnit}px;
`;
export const { RangePicker } = AntdDatePicker;
export const DatePicker = AntdDatePicker; export const DatePicker = AntdDatePicker;

View File

@ -16,24 +16,21 @@
* 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, useMemo } from 'react';
import { styled, withTheme, SupersetThemeProps, t } from '@superset-ui/core'; import { styled, t } from '@superset-ui/core';
import { PartialThemeConfig, Select } from 'src/components/Select'; import { Select } from 'src/components';
import { FormLabel } from 'src/components/Form';
import { SELECT_WIDTH } from './utils';
import { CardSortSelectOption, FetchDataConfig, SortColumn } from './types'; import { CardSortSelectOption, FetchDataConfig, SortColumn } from './types';
import { filterSelectStyles } from './utils';
const SortTitle = styled.label`
font-weight: bold;
line-height: 27px;
margin: 0 0.4em 0 0;
`;
const SortContainer = styled.div` const SortContainer = styled.div`
display: inline-flex; display: inline-flex;
font-size: ${({ theme }) => theme.typography.sizes.s}px; font-size: ${({ theme }) => theme.typography.sizes.s}px;
padding-top: ${({ theme }) => theme.gridUnit}px; align-items: center;
text-align: left; text-align: left;
width: ${SELECT_WIDTH}px;
`; `;
interface CardViewSelectSortProps { interface CardViewSelectSortProps {
onChange: (conf: FetchDataConfig) => any; onChange: (conf: FetchDataConfig) => any;
options: Array<CardSortSelectOption>; options: Array<CardSortSelectOption>;
@ -42,43 +39,6 @@ interface CardViewSelectSortProps {
pageSize: number; pageSize: number;
} }
interface StyledSelectProps {
onChange: (value: CardSortSelectOption) => void;
options: CardSortSelectOption[];
selectStyles: any;
theme: SupersetThemeProps['theme'];
value: CardSortSelectOption;
}
function StyledSelect({
onChange,
options,
selectStyles,
theme,
value,
}: StyledSelectProps) {
const filterSelectTheme: PartialThemeConfig = {
spacing: {
baseUnit: 1,
fontSize: theme.typography.sizes.s,
minWidth: '5em',
},
};
return (
<Select
data-test="card-sort-select"
clearable={false}
onChange={onChange}
options={options}
stylesConfig={selectStyles}
themeConfig={filterSelectTheme}
value={value}
/>
);
}
const StyledCardSortSelect = withTheme(StyledSelect);
export const CardSortSelect = ({ export const CardSortSelect = ({
initialSort, initialSort,
onChange, onChange,
@ -87,25 +47,45 @@ export const CardSortSelect = ({
pageSize, pageSize,
}: CardViewSelectSortProps) => { }: CardViewSelectSortProps) => {
const defaultSort = const defaultSort =
initialSort && options.find(({ id }) => id === initialSort[0].id); (initialSort && options.find(({ id }) => id === initialSort[0].id)) ||
const [selectedOption, setSelectedOption] = useState<CardSortSelectOption>( options[0];
defaultSort || options[0],
const [value, setValue] = useState({
label: defaultSort.label,
value: defaultSort.value,
});
const formattedOptions = useMemo(
() => options.map(option => ({ label: option.label, value: option.value })),
[options],
); );
const handleOnChange = (selected: CardSortSelectOption) => { const handleOnChange = (selected: { label: string; value: string }) => {
setSelectedOption(selected); setValue(selected);
const sortBy = [{ id: selected.id, desc: selected.desc }]; const originalOption = options.find(
onChange({ pageIndex, pageSize, sortBy, filters: [] }); ({ value }) => value === selected.value,
);
if (originalOption) {
const sortBy = [
{
id: originalOption.id,
desc: originalOption.desc,
},
];
onChange({ pageIndex, pageSize, sortBy, filters: [] });
}
}; };
return ( return (
<SortContainer> <SortContainer>
<SortTitle>{t('Sort:')}</SortTitle> <Select
<StyledCardSortSelect ariaLabel={t('Sort')}
header={<FormLabel>{t('Sort')}</FormLabel>}
labelInValue
onChange={(value: CardSortSelectOption) => handleOnChange(value)} onChange={(value: CardSortSelectOption) => handleOnChange(value)}
options={options} options={formattedOptions}
selectStyles={filterSelectStyles} showSearch
value={selectedOption} value={value}
/> />
</SortContainer> </SortContainer>
); );

View File

@ -18,6 +18,7 @@
*/ */
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { styled } from '@superset-ui/core'; import { styled } from '@superset-ui/core';
import { SELECT_WIDTH } from 'src/components/ListView/utils';
export interface BaseFilter { export interface BaseFilter {
Header: ReactNode; Header: ReactNode;
@ -26,12 +27,7 @@ export interface BaseFilter {
export const FilterContainer = styled.div` export const FilterContainer = styled.div`
display: inline-flex; display: inline-flex;
margin-right: 2em;
font-size: ${({ theme }) => theme.typography.sizes.s}px; font-size: ${({ theme }) => theme.typography.sizes.s}px;
align-items: center; align-items: center;
`; width: ${SELECT_WIDTH}px;
export const FilterTitle = styled.label`
font-weight: bold;
margin: 0 0.4em 0 0;
`; `;

View File

@ -19,8 +19,9 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import moment, { Moment } from 'moment'; import moment, { Moment } from 'moment';
import { styled } from '@superset-ui/core'; import { styled } from '@superset-ui/core';
import { RangePicker as AntRangePicker } from 'src/components/DatePicker'; import { RangePicker } from 'src/components/DatePicker';
import { FilterContainer, BaseFilter, FilterTitle } from './Base'; import { FormLabel } from 'src/components/Form';
import { BaseFilter } from './Base';
interface DateRangeFilterProps extends BaseFilter { interface DateRangeFilterProps extends BaseFilter {
onSubmit: (val: number[]) => void; onSubmit: (val: number[]) => void;
@ -29,13 +30,12 @@ interface DateRangeFilterProps extends BaseFilter {
type ValueState = [number, number]; type ValueState = [number, number];
const RangePicker = styled(AntRangePicker)` const RangeFilterContainer = styled.div`
padding: 0 11px; display: inline-flex;
transform: translateX(-7px); flex-direction: column;
`; justify-content: center;
align-items: flex-start;
const RangeFilterContainer = styled(FilterContainer)` width: 360px;
margin-right: 1em;
`; `;
export default function DateRangeFilter({ export default function DateRangeFilter({
@ -51,10 +51,9 @@ export default function DateRangeFilter({
return ( return (
<RangeFilterContainer> <RangeFilterContainer>
<FilterTitle>{Header}:</FilterTitle> <FormLabel>{Header}</FormLabel>
<RangePicker <RangePicker
showTime showTime
bordered={false}
value={momentValue} value={momentValue}
onChange={momentRange => { onChange={momentRange => {
if (!momentRange) { if (!momentRange) {

View File

@ -17,8 +17,12 @@
* under the License. * under the License.
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import SearchInput from 'src/components/SearchInput'; import { t, styled } from '@superset-ui/core';
import { FilterContainer, BaseFilter } from './Base'; import Icons from 'src/components/Icons';
import { AntdInput as Input } from 'src/common/components';
import { SELECT_WIDTH } from 'src/components/ListView/utils';
import { FormLabel } from 'src/components/Form';
import { BaseFilter } from './Base';
interface SearchHeaderProps extends BaseFilter { interface SearchHeaderProps extends BaseFilter {
Header: string; Header: string;
@ -26,6 +30,18 @@ interface SearchHeaderProps extends BaseFilter {
name: string; name: string;
} }
const Container = styled.div`
width: ${SELECT_WIDTH}px;
`;
const SearchIcon = styled(Icons.Search)`
color: ${({ theme }) => theme.colors.grayscale.light1};
`;
const StyledInput = styled(Input)`
border-radius: ${({ theme }) => theme.gridUnit}px;
`;
export default function SearchFilter({ export default function SearchFilter({
Header, Header,
name, name,
@ -38,28 +54,27 @@ export default function SearchFilter({
onSubmit(value.trim()); onSubmit(value.trim());
} }
}; };
const onClear = () => {
setValue('');
onSubmit('');
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.currentTarget.value); setValue(e.currentTarget.value);
if (e.currentTarget.value === '') { if (e.currentTarget.value === '') {
onClear(); onSubmit('');
} }
}; };
return ( return (
<FilterContainer> <Container>
<SearchInput <FormLabel>{Header}</FormLabel>
<StyledInput
allowClear
data-test="filters-search" data-test="filters-search"
placeholder={Header} placeholder={t('Type a value')}
name={name} name={name}
value={value} value={value}
onChange={handleChange} onChange={handleChange}
onSubmit={handleSubmit} onPressEnter={handleSubmit}
onClear={onClear} onBlur={handleSubmit}
prefix={<SearchIcon iconSize="l" />}
/> />
</FilterContainer> </Container>
); );
} }

View File

@ -16,138 +16,76 @@
* 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, useMemo } from 'react';
import { withTheme, SupersetThemeProps } from '@superset-ui/core'; import { t } from '@superset-ui/core';
import { import { Select } from 'src/components';
Select,
PaginatedSelect,
PartialThemeConfig,
} from 'src/components/Select';
import { Filter, SelectOption } from 'src/components/ListView/types'; import { Filter, SelectOption } from 'src/components/ListView/types';
import { filterSelectStyles } from 'src/components/ListView/utils'; import { FormLabel } from 'src/components/Form';
import { FilterContainer, BaseFilter, FilterTitle } from './Base'; import { FilterContainer, BaseFilter } from './Base';
interface SelectFilterProps extends BaseFilter { interface SelectFilterProps extends BaseFilter {
emptyLabel?: string;
fetchSelects?: Filter['fetchSelects']; fetchSelects?: Filter['fetchSelects'];
name?: string; name?: string;
onSelect: (selected: any) => any; onSelect: (selected: SelectOption | undefined) => void;
paginate?: boolean; paginate?: boolean;
selects: Filter['selects']; selects: Filter['selects'];
theme: SupersetThemeProps['theme'];
} }
const CLEAR_SELECT_FILTER_VALUE = 'CLEAR_SELECT_FILTER_VALUE';
function SelectFilter({ function SelectFilter({
Header, Header,
emptyLabel = 'None', name,
fetchSelects, fetchSelects,
initialValue, initialValue,
onSelect, onSelect,
paginate = false,
selects = [], selects = [],
theme,
}: SelectFilterProps) { }: SelectFilterProps) {
const filterSelectTheme: PartialThemeConfig = { const [selectedOption, setSelectedOption] = useState(initialValue);
spacing: {
baseUnit: 2,
fontSize: theme.typography.sizes.s,
minWidth: '5em',
},
};
const clearFilterSelect = { const onChange = (selected: SelectOption) => {
label: emptyLabel,
value: CLEAR_SELECT_FILTER_VALUE,
};
const options = [clearFilterSelect, ...selects];
let initialOption = clearFilterSelect;
// Set initial value if not async
if (!fetchSelects) {
const matchingOption = options.find(x => x.value === initialValue);
if (matchingOption) {
initialOption = matchingOption;
}
}
const [selectedOption, setSelectedOption] = useState(initialOption);
const onChange = (selected: SelectOption | null) => {
if (selected === null) return;
onSelect( onSelect(
selected.value === CLEAR_SELECT_FILTER_VALUE ? undefined : selected.value, selected ? { label: selected.label, value: selected.value } : undefined,
); );
setSelectedOption(selected); setSelectedOption(selected);
}; };
const fetchAndFormatSelects = async ( const onClear = () => {
inputValue: string, onSelect(undefined);
loadedOptions: SelectOption[], setSelectedOption(undefined);
{ page }: { page: number },
) => {
// only include clear filter when filter value does not exist
let result = inputValue || page > 0 ? [] : [clearFilterSelect];
let hasMore = paginate;
if (fetchSelects) {
const selectValues = await fetchSelects(inputValue, page);
// update matching option at initial load
if (!selectValues.length) {
hasMore = false;
}
result = [...result, ...selectValues];
const matchingOption = result.find(x => x.value === initialValue);
if (matchingOption) {
setSelectedOption(matchingOption);
}
}
return {
options: result,
hasMore,
additional: {
page: page + 1,
},
};
}; };
const fetchAndFormatSelects = useMemo(
() => async (inputValue: string, page: number, pageSize: number) => {
if (fetchSelects) {
const selectValues = await fetchSelects(inputValue, page, pageSize);
return {
data: selectValues.data,
totalCount: selectValues.totalCount,
};
}
return {
data: [],
totalCount: 0,
};
},
[fetchSelects],
);
return ( return (
<FilterContainer> <FilterContainer>
<FilterTitle>{Header}:</FilterTitle> <Select
{fetchSelects ? ( allowClear
<PaginatedSelect ariaLabel={typeof Header === 'string' ? Header : name || t('Filter')}
data-test="filters-select" labelInValue
defaultOptions data-test="filters-select"
themeConfig={filterSelectTheme} header={<FormLabel>{Header}</FormLabel>}
stylesConfig={filterSelectStyles} onChange={onChange}
// @ts-ignore onClear={onClear}
value={selectedOption} options={fetchSelects ? fetchAndFormatSelects : selects}
// @ts-ignore placeholder={t('Select or type a value')}
onChange={onChange} showSearch
// @ts-ignore value={selectedOption}
loadOptions={fetchAndFormatSelects} />
placeholder={emptyLabel}
clearable={false}
additional={{
page: 0,
}}
/>
) : (
<Select
data-test="filters-select"
themeConfig={filterSelectTheme}
stylesConfig={filterSelectStyles}
value={selectedOption}
options={options}
onChange={onChange}
clearable={false}
/>
)}
</FilterContainer> </FilterContainer>
); );
} }
export default withTheme(SelectFilter); export default SelectFilter;

View File

@ -17,12 +17,13 @@
* under the License. * under the License.
*/ */
import React from 'react'; import React from 'react';
import { styled, withTheme } from '@superset-ui/core'; import { withTheme } from '@superset-ui/core';
import { import {
FilterValue, FilterValue,
Filters, Filters,
InternalFilter, InternalFilter,
SelectOption,
} from 'src/components/ListView/types'; } from 'src/components/ListView/types';
import SearchFilter from './Search'; import SearchFilter from './Search';
import SelectFilter from './Select'; import SelectFilter from './Select';
@ -34,42 +35,28 @@ interface UIFiltersProps {
updateFilterValue: (id: number, value: FilterValue['value']) => void; updateFilterValue: (id: number, value: FilterValue['value']) => void;
} }
const FilterWrapper = styled.div`
display: inline-block;
`;
function UIFilters({ function UIFilters({
filters, filters,
internalFilters = [], internalFilters = [],
updateFilterValue, updateFilterValue,
}: UIFiltersProps) { }: UIFiltersProps) {
return ( return (
<FilterWrapper> <>
{filters.map( {filters.map(
( ({ Header, fetchSelects, id, input, paginate, selects }, index) => {
{
Header,
fetchSelects,
id,
input,
paginate,
selects,
unfilteredLabel,
},
index,
) => {
const initialValue = const initialValue =
internalFilters[index] && internalFilters[index].value; internalFilters[index] && internalFilters[index].value;
if (input === 'select') { if (input === 'select') {
return ( return (
<SelectFilter <SelectFilter
Header={Header} Header={Header}
emptyLabel={unfilteredLabel}
fetchSelects={fetchSelects} fetchSelects={fetchSelects}
initialValue={initialValue} initialValue={initialValue}
key={id} key={id}
name={id} name={id}
onSelect={(value: any) => updateFilterValue(index, value)} onSelect={(option: SelectOption | undefined) =>
updateFilterValue(index, option)
}
paginate={paginate} paginate={paginate}
selects={selects} selects={selects}
/> />
@ -100,7 +87,7 @@ function UIFilters({
return null; return null;
}, },
)} )}
</FilterWrapper> </>
); );
} }

View File

@ -377,8 +377,8 @@ describe('ListView', () => {
expect(wrapper.find(ListViewFilters)).toExist(); expect(wrapper.find(ListViewFilters)).toExist();
}); });
it('fetched async filter values on mount', () => { it('does not fetch async filter values on mount', () => {
expect(fetchSelectsMock).toHaveBeenCalled(); expect(fetchSelectsMock).not.toHaveBeenCalled();
}); });
it('calls fetchData on filter', () => { it('calls fetchData on filter', () => {
@ -387,7 +387,7 @@ describe('ListView', () => {
.find('[data-test="filters-select"]') .find('[data-test="filters-select"]')
.first() .first()
.props() .props()
.onChange({ value: 'bar' }); .onChange({ label: 'bar', value: 'bar' });
}); });
act(() => { act(() => {
@ -395,13 +395,15 @@ describe('ListView', () => {
.find('[data-test="filters-search"]') .find('[data-test="filters-search"]')
.first() .first()
.props() .props()
.onChange({ currentTarget: { value: 'something' } }); .onChange({
currentTarget: { label: 'something', value: 'something' },
});
}); });
wrapper.update(); wrapper.update();
act(() => { act(() => {
wrapper.find('[data-test="search-input"]').last().props().onBlur(); wrapper.find('[data-test="filters-search"]').last().props().onBlur();
}); });
expect(mockedProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(` expect(mockedProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
@ -411,7 +413,10 @@ describe('ListView', () => {
Object { Object {
"id": "id", "id": "id",
"operator": "eq", "operator": "eq",
"value": "bar", "value": Object {
"label": "bar",
"value": "bar",
},
}, },
], ],
"pageIndex": 0, "pageIndex": 0,
@ -433,7 +438,10 @@ describe('ListView', () => {
Object { Object {
"id": "id", "id": "id",
"operator": "eq", "operator": "eq",
"value": "bar", "value": Object {
"label": "bar",
"value": "bar",
},
}, },
Object { Object {
"id": "name", "id": "name",
@ -462,7 +470,7 @@ describe('ListView', () => {
}); });
await act(async () => { await act(async () => {
wrapper2.find('[data-test="card-sort-select"]').first().props().onChange({ wrapper2.find('[aria-label="Sort"]').first().props().onChange({
desc: false, desc: false,
id: 'something', id: 'something',
label: 'Alphabetical', label: 'Alphabetical',

View File

@ -50,13 +50,11 @@ const ListViewStyles = styled.div`
display: flex; display: flex;
padding-bottom: ${({ theme }) => theme.gridUnit * 4}px; padding-bottom: ${({ theme }) => theme.gridUnit * 4}px;
.header-left { & .controls {
display: flex; display: flex;
flex: 5; flex-wrap: wrap;
} column-gap: ${({ theme }) => theme.gridUnit * 6}px;
.header-right { row-gap: ${({ theme }) => theme.gridUnit * 4}px;
flex: 1;
text-align: right;
} }
} }
@ -136,6 +134,7 @@ const bulkSelectColumnConfig = {
const ViewModeContainer = styled.div` const ViewModeContainer = styled.div`
padding-right: ${({ theme }) => theme.gridUnit * 4}px; padding-right: ${({ theme }) => theme.gridUnit * 4}px;
margin-top: ${({ theme }) => theme.gridUnit * 5 + 1}px;
display: inline-block; display: inline-block;
.toggle-button { .toggle-button {
@ -301,10 +300,10 @@ function ListView<T extends object = any>({
<ListViewStyles> <ListViewStyles>
<div data-test={className} className={`superset-list-view ${className}`}> <div data-test={className} className={`superset-list-view ${className}`}>
<div className="header"> <div className="header">
<div className="header-left"> {cardViewEnabled && (
{cardViewEnabled && ( <ViewModeToggle mode={viewMode} setMode={setViewMode} />
<ViewModeToggle mode={viewMode} setMode={setViewMode} /> )}
)} <div className="controls">
{filterable && ( {filterable && (
<FilterControls <FilterControls
filters={filters} filters={filters}
@ -312,8 +311,6 @@ function ListView<T extends object = any>({
updateFilterValue={applyFilterValue} updateFilterValue={applyFilterValue}
/> />
)} )}
</div>
<div className="header-right">
{viewMode === 'card' && cardSortSelectOptions && ( {viewMode === 'card' && cardSortSelectOptions && (
<CardSortSelect <CardSortSelect
initialSort={initialSort} initialSort={initialSort}

View File

@ -53,10 +53,10 @@ export interface Filter {
selects?: SelectOption[]; selects?: SelectOption[];
onFilterOpen?: () => void; onFilterOpen?: () => void;
fetchSelects?: ( fetchSelects?: (
filterValue?: string, filterValue: string,
pageIndex?: number, page: number,
pageSize?: number, pageSize: number,
) => Promise<SelectOption[]>; ) => Promise<{ data: SelectOption[]; totalCount: number }>;
paginate?: boolean; paginate?: boolean;
} }
@ -68,7 +68,15 @@ export interface FilterValue {
id: string; id: string;
urlDisplay?: string; urlDisplay?: string;
operator?: string; operator?: string;
value: string | boolean | number | null | undefined | string[] | number[]; value:
| string
| boolean
| number
| null
| undefined
| string[]
| number[]
| { label: string; value: string | number };
} }
export interface FetchDataConfig { export interface FetchDataConfig {

View File

@ -55,6 +55,8 @@ const RisonParam: QueryParamConfig<string, any> = {
: rison.decode(dataStr), : rison.decode(dataStr),
}; };
export const SELECT_WIDTH = 200;
export class ListViewError extends Error { export class ListViewError extends Error {
name = 'ListViewError'; name = 'ListViewError';
} }

View File

@ -1,62 +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 React, { useState } from 'react';
import SearchInput, { SearchInputProps } from '.';
export default {
title: 'SearchInput',
component: SearchInput,
};
export const InteractiveSearchInput = ({
value,
...rest
}: SearchInputProps) => {
const [currentValue, setCurrentValue] = useState(value);
return (
<div style={{ width: 230 }}>
<SearchInput
{...rest}
value={currentValue}
onChange={e => setCurrentValue(e.target.value)}
onClear={() => setCurrentValue('')}
/>
</div>
);
};
InteractiveSearchInput.args = {
value: 'Test',
placeholder: 'Enter some text',
name: 'search-input',
};
InteractiveSearchInput.argTypes = {
onSubmit: { action: 'onSubmit' },
onClear: { action: 'onClear' },
onChange: { action: 'onChange' },
};
InteractiveSearchInput.story = {
parameters: {
knobs: {
disable: true,
},
},
};

View File

@ -1,93 +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 React from 'react';
import { mount } from 'enzyme';
import { ThemeProvider, supersetTheme } from '@superset-ui/core';
import SearchInput from 'src/components/SearchInput';
describe('SearchInput', () => {
const defaultProps = {
onSubmit: jest.fn(),
onClear: jest.fn(),
onChange: jest.fn(),
value: '',
};
const factory = overrideProps => {
const props = { ...defaultProps, ...(overrideProps || {}) };
return mount(
<ThemeProvider theme={supersetTheme}>
<SearchInput {...props} />
</ThemeProvider>,
);
};
let wrapper;
beforeAll(() => {
wrapper = factory();
});
afterEach(() => {
defaultProps.onSubmit.mockClear();
defaultProps.onClear.mockClear();
defaultProps.onChange.mockClear();
});
it('renders', () => {
expect(React.isValidElement(<SearchInput {...defaultProps} />)).toBe(true);
});
const typeSearchInput = value => {
wrapper
.find('[data-test="search-input"]')
.first()
.props()
.onChange({ currentTarget: { value } });
};
it('submits on enter', () => {
typeSearchInput('foo');
wrapper
.find('[data-test="search-input"]')
.first()
.props()
.onKeyDown({ key: 'Enter' });
expect(defaultProps.onChange).toHaveBeenCalled();
expect(defaultProps.onSubmit).toHaveBeenCalled();
});
it('submits on search icon click', () => {
typeSearchInput('bar');
wrapper.find('[data-test="search-submit"]').first().props().onClick();
expect(defaultProps.onSubmit).toHaveBeenCalled();
});
it('clears on clear icon click', () => {
const wrapper2 = factory({ value: 'fizz' });
wrapper2.find('[data-test="search-clear"]').first().props().onClick();
expect(defaultProps.onClear).toHaveBeenCalled();
});
});

View File

@ -1,108 +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 { styled, useTheme } from '@superset-ui/core';
import React from 'react';
import Icons from 'src/components/Icons';
export interface SearchInputProps {
onSubmit: () => void;
onClear: () => void;
value: string;
onChange: React.EventHandler<React.ChangeEvent<HTMLInputElement>>;
placeholder?: string;
name?: string;
}
const SearchInputWrapper = styled.div`
position: relative;
`;
const StyledInput = styled.input`
width: 200px;
height: ${({ theme }) => theme.gridUnit * 8}px;
background-image: none;
border: 1px solid ${({ theme }) => theme.colors.secondary.light2};
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
padding: 4px 28px;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
&:focus {
outline: none;
}
`;
const commonStyles = `
position: absolute;
z-index: 2;
display: block;
cursor: pointer;
`;
const SearchIcon = styled(Icons.Search)`
${commonStyles};
top: 4px;
left: 2px;
`;
const ClearIcon = styled(Icons.CancelX)`
${commonStyles};
right: 0px;
top: 4px;
`;
export default function SearchInput({
onChange,
onClear,
onSubmit,
placeholder = 'Search',
name,
value,
}: SearchInputProps) {
const theme = useTheme();
return (
<SearchInputWrapper>
<SearchIcon
iconColor={theme.colors.grayscale.base}
data-test="search-submit"
role="button"
onClick={() => onSubmit()}
/>
<StyledInput
data-test="search-input"
onKeyDown={e => {
if (e.key === 'Enter') {
onSubmit();
}
}}
onBlur={() => onSubmit()}
placeholder={placeholder}
onChange={onChange}
value={value}
name={name}
/>
{value && (
<ClearIcon
data-test="search-clear"
role="button"
iconColor={theme.colors.grayscale.base}
onClick={() => onClear()}
/>
)}
</SearchInputWrapper>
);
}

View File

@ -56,6 +56,8 @@ interface AlertListProps {
isReportEnabled: boolean; isReportEnabled: boolean;
user: { user: {
userId: string | number; userId: string | number;
firstName: string;
lastName: string;
}; };
} }
const deleteAlerts = makeApi<number[], { message: string }>({ const deleteAlerts = makeApi<number[], { message: string }>({
@ -381,7 +383,7 @@ function AlertList({
createErrorHandler(errMsg => createErrorHandler(errMsg =>
t('An error occurred while fetching created by values: %s', errMsg), t('An error occurred while fetching created by values: %s', errMsg),
), ),
user.userId, user,
), ),
paginate: true, paginate: true,
}, },

View File

@ -46,6 +46,8 @@ interface AnnotationLayersListProps {
addSuccessToast: (msg: string) => void; addSuccessToast: (msg: string) => void;
user: { user: {
userId: string | number; userId: string | number;
firstName: string;
lastName: string;
}; };
} }
@ -301,7 +303,7 @@ function AnnotationLayersList({
errMsg, errMsg,
), ),
), ),
user.userId, user,
), ),
paginate: true, paginate: true,
}, },

View File

@ -77,42 +77,40 @@ const CONFIRM_OVERWRITE_MESSAGE = t(
setupPlugins(); setupPlugins();
const registry = getChartMetadataRegistry(); const registry = getChartMetadataRegistry();
const createFetchDatasets = (handleError: (err: Response) => void) => async ( const createFetchDatasets = async (
filterValue = '', filterValue = '',
pageIndex?: number, page: number,
pageSize?: number, pageSize: number,
) => { ) => {
// add filters if filterValue // add filters if filterValue
const filters = filterValue const filters = filterValue
? { filters: [{ col: 'table_name', opr: 'sw', value: filterValue }] } ? { filters: [{ col: 'table_name', opr: 'sw', value: filterValue }] }
: {}; : {};
try { const queryParams = rison.encode({
const queryParams = rison.encode({ columns: ['datasource_name', 'datasource_id'],
columns: ['datasource_name', 'datasource_id'], keys: ['none'],
keys: ['none'], order_column: 'table_name',
order_column: 'table_name', order_direction: 'asc',
order_direction: 'asc', page,
...(pageIndex ? { page: pageIndex } : {}), page_size: pageSize,
...(pageSize ? { page_size: pageSize } : {}), ...filters,
...filters, });
});
const { json = {} } = await SupersetClient.get({ const { json = {} } = await SupersetClient.get({
endpoint: `/api/v1/dataset/?q=${queryParams}`, endpoint: `/api/v1/dataset/?q=${queryParams}`,
}); });
const datasets = json?.result?.map( const datasets = json?.result?.map(
({ table_name: tableName, id }: { table_name: string; id: number }) => ({ ({ table_name: tableName, id }: { table_name: string; id: number }) => ({
label: tableName, label: tableName,
value: id, value: id,
}), }),
); );
return uniqBy<SelectOption>(datasets, 'value'); return {
} catch (e) { data: uniqBy<SelectOption>(datasets, 'value'),
handleError(e); totalCount: json?.count,
} };
return [];
}; };
interface ChartListProps { interface ChartListProps {
@ -120,6 +118,8 @@ interface ChartListProps {
addSuccessToast: (msg: string) => void; addSuccessToast: (msg: string) => void;
user: { user: {
userId: string | number; userId: string | number;
firstName: string;
lastName: string;
}; };
} }
@ -417,113 +417,110 @@ function ChartList(props: ChartListProps) {
], ],
); );
const favoritesFilter: Filter = { const favoritesFilter: Filter = useMemo(
Header: t('Favorite'), () => ({
id: 'id', Header: t('Favorite'),
urlDisplay: 'favorite', id: 'id',
input: 'select', urlDisplay: 'favorite',
operator: FilterOperator.chartIsFav, input: 'select',
unfilteredLabel: t('Any'), operator: FilterOperator.chartIsFav,
selects: [ unfilteredLabel: t('Any'),
{ label: t('Yes'), value: true }, selects: [
{ label: t('No'), value: false }, { label: t('Yes'), value: true },
], { label: t('No'), value: false },
}; ],
}),
[],
);
const filters: Filters = [ const filters: Filters = useMemo(
{ () => [
Header: t('Owner'), {
id: 'owners', Header: t('Owner'),
input: 'select', id: 'owners',
operator: FilterOperator.relationManyMany, input: 'select',
unfilteredLabel: t('All'), operator: FilterOperator.relationManyMany,
fetchSelects: createFetchRelated( unfilteredLabel: t('All'),
'chart', fetchSelects: createFetchRelated(
'owners', 'chart',
createErrorHandler(errMsg => 'owners',
addDangerToast( createErrorHandler(errMsg =>
t( addDangerToast(
'An error occurred while fetching chart owners values: %s', t(
errMsg, 'An error occurred while fetching chart owners values: %s',
errMsg,
),
), ),
), ),
props.user,
), ),
props.user.userId, paginate: true,
), },
paginate: true, {
}, Header: t('Created by'),
{ id: 'created_by',
Header: t('Created by'), input: 'select',
id: 'created_by', operator: FilterOperator.relationOneMany,
input: 'select', unfilteredLabel: t('All'),
operator: FilterOperator.relationOneMany, fetchSelects: createFetchRelated(
unfilteredLabel: t('All'), 'chart',
fetchSelects: createFetchRelated( 'created_by',
'chart', createErrorHandler(errMsg =>
'created_by', addDangerToast(
createErrorHandler(errMsg => t(
addDangerToast( 'An error occurred while fetching chart created by values: %s',
t( errMsg,
'An error occurred while fetching chart created by values: %s', ),
errMsg,
), ),
), ),
props.user,
), ),
props.user.userId, paginate: true,
), },
paginate: true, {
}, Header: t('Viz type'),
{ id: 'viz_type',
Header: t('Viz type'), input: 'select',
id: 'viz_type', operator: FilterOperator.equals,
input: 'select', unfilteredLabel: t('All'),
operator: FilterOperator.equals, selects: registry
unfilteredLabel: t('All'), .keys()
selects: registry .filter(k => nativeFilterGate(registry.get(k)?.behaviors || []))
.keys() .map(k => ({ label: registry.get(k)?.name || k, value: k }))
.filter(k => nativeFilterGate(registry.get(k)?.behaviors || [])) .sort((a, b) => {
.map(k => ({ label: registry.get(k)?.name || k, value: k })) if (!a.label || !b.label) {
.sort((a, b) => { return 0;
if (!a.label || !b.label) { }
if (a.label > b.label) {
return 1;
}
if (a.label < b.label) {
return -1;
}
return 0; return 0;
} }),
},
if (a.label > b.label) { {
return 1; Header: t('Dataset'),
} id: 'datasource_id',
if (a.label < b.label) { input: 'select',
return -1; operator: FilterOperator.equals,
} unfilteredLabel: t('All'),
fetchSelects: createFetchDatasets,
return 0; paginate: true,
}), },
}, ...(props.user.userId ? [favoritesFilter] : []),
{ {
Header: t('Dataset'), Header: t('Search'),
id: 'datasource_id', id: 'slice_name',
input: 'select', input: 'search',
operator: FilterOperator.equals, operator: FilterOperator.chartAllText,
unfilteredLabel: t('All'), },
fetchSelects: createFetchDatasets( ],
createErrorHandler(errMsg => [addDangerToast, favoritesFilter, props.user],
addDangerToast( );
t(
'An error occurred while fetching chart dataset values: %s',
errMsg,
),
),
),
),
paginate: true,
},
...(props.user.userId ? [favoritesFilter] : []),
{
Header: t('Search'),
id: 'slice_name',
input: 'search',
operator: FilterOperator.chartAllText,
},
];
const sortTypes = [ const sortTypes = [
{ {

View File

@ -45,6 +45,8 @@ interface CssTemplatesListProps {
addSuccessToast: (msg: string) => void; addSuccessToast: (msg: string) => void;
user: { user: {
userId: string | number; userId: string | number;
firstName: string;
lastName: string;
}; };
} }
@ -287,7 +289,7 @@ function CssTemplatesList({
errMsg, errMsg,
), ),
), ),
user.userId, user,
), ),
paginate: true, paginate: true,
}, },

View File

@ -71,6 +71,8 @@ interface DashboardListProps {
addSuccessToast: (msg: string) => void; addSuccessToast: (msg: string) => void;
user: { user: {
userId: string | number; userId: string | number;
firstName: string;
lastName: string;
}; };
} }
@ -404,81 +406,87 @@ function DashboardList(props: DashboardListProps) {
], ],
); );
const favoritesFilter: Filter = { const favoritesFilter: Filter = useMemo(
Header: t('Favorite'), () => ({
id: 'id', Header: t('Favorite'),
urlDisplay: 'favorite', id: 'id',
input: 'select', urlDisplay: 'favorite',
operator: FilterOperator.dashboardIsFav,
unfilteredLabel: t('Any'),
selects: [
{ label: t('Yes'), value: true },
{ label: t('No'), value: false },
],
};
const filters: Filters = [
{
Header: t('Owner'),
id: 'owners',
input: 'select', input: 'select',
operator: FilterOperator.relationManyMany, operator: FilterOperator.dashboardIsFav,
unfilteredLabel: t('All'),
fetchSelects: createFetchRelated(
'dashboard',
'owners',
createErrorHandler(errMsg =>
addDangerToast(
t(
'An error occurred while fetching dashboard owner values: %s',
errMsg,
),
),
),
props.user.userId,
),
paginate: true,
},
{
Header: t('Created by'),
id: 'created_by',
input: 'select',
operator: FilterOperator.relationOneMany,
unfilteredLabel: t('All'),
fetchSelects: createFetchRelated(
'dashboard',
'created_by',
createErrorHandler(errMsg =>
addDangerToast(
t(
'An error occurred while fetching dashboard created by values: %s',
errMsg,
),
),
),
props.user.userId,
),
paginate: true,
},
{
Header: t('Status'),
id: 'published',
input: 'select',
operator: FilterOperator.equals,
unfilteredLabel: t('Any'), unfilteredLabel: t('Any'),
selects: [ selects: [
{ label: t('Published'), value: true }, { label: t('Yes'), value: true },
{ label: t('Draft'), value: false }, { label: t('No'), value: false },
], ],
}, }),
...(props.user.userId ? [favoritesFilter] : []), [],
{ );
Header: t('Search'),
id: 'dashboard_title', const filters: Filters = useMemo(
input: 'search', () => [
operator: FilterOperator.titleOrSlug, {
}, Header: t('Owner'),
]; id: 'owners',
input: 'select',
operator: FilterOperator.relationManyMany,
unfilteredLabel: t('All'),
fetchSelects: createFetchRelated(
'dashboard',
'owners',
createErrorHandler(errMsg =>
addDangerToast(
t(
'An error occurred while fetching dashboard owner values: %s',
errMsg,
),
),
),
props.user,
),
paginate: true,
},
{
Header: t('Created by'),
id: 'created_by',
input: 'select',
operator: FilterOperator.relationOneMany,
unfilteredLabel: t('All'),
fetchSelects: createFetchRelated(
'dashboard',
'created_by',
createErrorHandler(errMsg =>
addDangerToast(
t(
'An error occurred while fetching dashboard created by values: %s',
errMsg,
),
),
),
props.user,
),
paginate: true,
},
{
Header: t('Status'),
id: 'published',
input: 'select',
operator: FilterOperator.equals,
unfilteredLabel: t('Any'),
selects: [
{ label: t('Published'), value: true },
{ label: t('Draft'), value: false },
],
},
...(props.user.userId ? [favoritesFilter] : []),
{
Header: t('Search'),
id: 'dashboard_title',
input: 'search',
operator: FilterOperator.titleOrSlug,
},
],
[addDangerToast, favoritesFilter, props.user],
);
const sortTypes = [ const sortTypes = [
{ {

View File

@ -161,13 +161,13 @@ describe('DatabaseList', () => {
.find('[name="expose_in_sqllab"]') .find('[name="expose_in_sqllab"]')
.first() .first()
.props() .props()
.onSelect(true); .onSelect({ label: 'Yes', value: true });
filtersWrapper filtersWrapper
.find('[name="allow_run_async"]') .find('[name="allow_run_async"]')
.first() .first()
.props() .props()
.onSelect(false); .onSelect({ label: 'Yes', value: false });
filtersWrapper filtersWrapper
.find('[name="database_name"]') .find('[name="database_name"]')

View File

@ -118,12 +118,14 @@ describe('DatasetList', () => {
); );
}); });
it('fetches owner filter values', () => { it('does not fetch owner filter values on mount', async () => {
expect(fetchMock.calls(/dataset\/related\/owners/)).toHaveLength(1); await waitForComponentToPaint(wrapper);
expect(fetchMock.calls(/dataset\/related\/owners/)).toHaveLength(0);
}); });
it('fetches schema filter values', () => { it('does not fetch schema filter values on mount', async () => {
expect(fetchMock.calls(/dataset\/distinct\/schema/)).toHaveLength(1); await waitForComponentToPaint(wrapper);
expect(fetchMock.calls(/dataset\/distinct\/schema/)).toHaveLength(0);
}); });
it('shows/hides bulk actions when bulk actions is clicked', async () => { it('shows/hides bulk actions when bulk actions is clicked', async () => {

View File

@ -98,6 +98,8 @@ interface DatasetListProps {
addSuccessToast: (msg: string) => void; addSuccessToast: (msg: string) => void;
user: { user: {
userId: string | number; userId: string | number;
firstName: string;
lastName: string;
}; };
} }
@ -414,7 +416,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
errMsg, errMsg,
), ),
), ),
user.userId, user,
), ),
paginate: true, paginate: true,
}, },

View File

@ -374,7 +374,7 @@ function QueryList({ addDangerToast, addSuccessToast }: QueryListProps) {
'user', 'user',
createErrorHandler(errMsg => createErrorHandler(errMsg =>
addDangerToast( addDangerToast(
t('An error occurred while fetching database values: %s', errMsg), t('An error occurred while fetching user values: %s', errMsg),
), ),
), ),
), ),

View File

@ -271,7 +271,7 @@ describe('RTL', () => {
it('renders an export button in the actions bar', async () => { it('renders an export button in the actions bar', async () => {
// Grab Export action button and mock mouse hovering over it // Grab Export action button and mock mouse hovering over it
const exportActionButton = screen.getAllByRole('button')[18]; const exportActionButton = screen.getAllByTestId('export-action')[0];
userEvent.hover(exportActionButton); userEvent.hover(exportActionButton);
// Wait for the tooltip to pop up // Wait for the tooltip to pop up

View File

@ -141,7 +141,10 @@ export function useListViewResource<D extends object = any>(
.map(({ id, operator: opr, value }) => ({ .map(({ id, operator: opr, value }) => ({
col: id, col: id,
opr, opr,
value, value:
value && typeof value === 'object' && 'value' in value
? value.value
: value,
})); }));
const queryParams = rison.encode({ const queryParams = rison.encode({

View File

@ -37,32 +37,52 @@ const createFetchResourceMethod = (method: string) => (
resource: string, resource: string,
relation: string, relation: string,
handleError: (error: Response) => void, handleError: (error: Response) => void,
userId?: string | number, user?: { userId: string | number; firstName: string; lastName: string },
) => async (filterValue = '', pageIndex?: number, pageSize?: number) => { ) => async (filterValue = '', page: number, pageSize: number) => {
const resourceEndpoint = `/api/v1/${resource}/${method}/${relation}`; const resourceEndpoint = `/api/v1/${resource}/${method}/${relation}`;
const options = const queryParams = rison.encode({
userId && pageIndex === 0 ? [{ label: 'me', value: userId }] : []; filter: filterValue,
try { page,
const queryParams = rison.encode({ page_size: pageSize,
...(pageIndex ? { page: pageIndex } : {}), });
...(pageSize ? { page_size: pageSize } : {}), const { json = {} } = await SupersetClient.get({
...(filterValue ? { filter: filterValue } : {}), endpoint: `${resourceEndpoint}?q=${queryParams}`,
}); });
const { json = {} } = await SupersetClient.get({
endpoint: `${resourceEndpoint}?q=${queryParams}`,
});
const data = json?.result?.map(
({ text: label, value }: { text: string; value: any }) => ({
label,
value,
}),
);
return options.concat(data); let fetchedLoggedUser = false;
} catch (e) { const loggedUser = user
handleError(e); ? {
label: `${user.firstName} ${user.lastName}`,
value: user.userId,
}
: undefined;
const data: { label: string; value: string | number }[] = [];
json?.result?.forEach(
({ text, value }: { text: string; value: string | number }) => {
if (
loggedUser &&
value === loggedUser.value &&
text === loggedUser.label
) {
fetchedLoggedUser = true;
} else {
data.push({
label: text,
value,
});
}
},
);
if (loggedUser && (!filterValue || fetchedLoggedUser)) {
data.unshift(loggedUser);
} }
return [];
return {
data,
totalCount: json?.count,
};
}; };
export const PAGE_SIZE = 5; export const PAGE_SIZE = 5;