[Dashboard] new listview filters & emotion infra (#9462)

* [Dashboard] listview filters to match new design

* use title_or_slug filter

* use ?. operator

* move components to components folder
This commit is contained in:
ʈᵃᵢ 2020-04-13 13:39:55 -07:00 committed by GitHub
parent a797465ae5
commit f90824fa17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1361 additions and 433 deletions

View File

@ -34,13 +34,10 @@ module.exports = {
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
globals: {
'ts-jest': {
babelConfig: true,
diagnostics: {
warnOnly: true,
},
tsConfig: {
jsx: 'react',
esModuleInterop: true,
},
},
},
};

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@
"prod": "node --max_old_space_size=4096 ./node_modules/webpack/bin/webpack.js --mode=production --colors --progress",
"build-dev": "cross-env NODE_OPTIONS=--max_old_space_size=8192 NODE_ENV=development webpack --mode=development --colors --progress",
"build": "cross-env NODE_OPTIONS=--max_old_space_size=8192 NODE_ENV=production webpack --mode=production --colors --progress",
"lint": "eslint --ignore-path=.eslintignore --ext .js,.jsx,.ts,.tsx . && prettier --check '{src,stylesheets}/**/*.{css,less,sass,scss}'",
"lint": "prettier --check '{src,stylesheets}/**/*.{css,less,sass,scss}' && eslint --ignore-path=.eslintignore --ext .js,.jsx,.ts,.tsx .",
"lint-fix": "eslint --fix --ignore-path=.eslintignore --ext .js,.jsx,.ts,tsx . && npm run clean-css",
"clean-css": "prettier --write '{src,stylesheets}/**/*.{css,less,sass,scss}'"
},
@ -53,6 +53,8 @@
"dependencies": {
"@babel/runtime-corejs3": "^7.8.4",
"@data-ui/sparkline": "^0.0.54",
"@emotion/core": "^10.0.28",
"@emotion/styled": "^10.0.27",
"@superset-ui/chart": "^0.12.11",
"@superset-ui/chart-composition": "^0.12.8",
"@superset-ui/color": "^0.12.8",
@ -108,6 +110,7 @@
"d3-scale": "^2.1.2",
"dnd-core": "^2.6.0",
"dompurify": "^2.0.7",
"emotion-theming": "^10.0.27",
"geolib": "^2.0.24",
"immutable": "^3.8.2",
"interweave": "^11.2.0",
@ -147,7 +150,7 @@
"react-split": "^2.0.4",
"react-sticky": "^6.0.2",
"react-syntax-highlighter": "^7.0.4",
"react-table": "^7.0.0-rc.15",
"react-table": "^7.0.4",
"react-transition-group": "^2.5.3",
"react-ultimate-pagination": "^1.2.0",
"react-virtualized": "9.19.1",
@ -175,10 +178,12 @@
"@babel/preset-react": "^7.8.3",
"@babel/register": "^7.8.6",
"@hot-loader/react-dom": "^16.13.0",
"@types/classnames": "^2.2.9",
"@types/jest": "^25.1.4",
"@types/jquery": "^3.3.32",
"@types/react": "^16.9.23",
"@types/react-dom": "^16.9.5",
"@types/react-json-tree": "^0.6.11",
"@types/react-redux": "^7.1.7",
"@types/react-table": "^7.0.2",
"@types/react-ultimate-pagination": "^1.2.0",
@ -189,12 +194,14 @@
"babel-jest": "^25.1.0",
"babel-loader": "^8.0.6",
"babel-plugin-dynamic-import-node": "^2.3.0",
"babel-plugin-emotion": "^10.0.29",
"babel-plugin-lodash": "^3.3.4",
"cache-loader": "^1.2.2",
"clean-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "^5.1.1",
"cross-env": "^5.2.0",
"css-loader": "^1.0.0",
"emotion-ts-plugin": "^0.5.3",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.14.0",
"eslint": "^6.2.2",

View File

@ -20,43 +20,45 @@ import React from 'react';
import { mount, shallow } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { MenuItem, Pagination } from 'react-bootstrap';
import Select from 'react-select';
import ListView from 'src/components/ListView/ListView';
import ListViewFilters from 'src/components/ListView/Filters';
import ListViewPagination from 'src/components/ListView/Pagination';
import { areArraysShallowEqual } from 'src/reduxUtils';
const mockedProps = {
title: 'Data Table',
columns: [
{
accessor: 'id',
Header: 'ID',
sortable: true,
},
{
accessor: 'name',
Header: 'Name',
},
],
filters: [
{
Header: 'Name',
id: 'name',
operators: [{ label: 'Starts With', value: 'sw' }],
},
],
data: [
{ id: 1, name: 'data 1' },
{ id: 2, name: 'data 2' },
],
count: 2,
pageSize: 1,
fetchData: jest.fn(() => []),
loading: false,
bulkActions: [{ name: 'do something', onSelect: jest.fn() }],
};
describe('ListView', () => {
const mockedProps = {
title: 'Data Table',
columns: [
{
accessor: 'id',
Header: 'ID',
sortable: true,
},
{
accessor: 'name',
Header: 'Name',
filterable: true,
},
],
filters: [
{
Header: 'Name',
id: 'name',
operators: [{ label: 'Starts With', value: 'sw' }],
},
],
data: [
{ id: 1, name: 'data 1' },
{ id: 2, name: 'data 2' },
],
count: 2,
pageSize: 1,
fetchData: jest.fn(() => []),
loading: false,
bulkActions: [{ name: 'do something', onSelect: jest.fn() }],
};
const wrapper = mount(<ListView {...mockedProps} />);
afterEach(() => {
@ -138,34 +140,35 @@ describe('ListView', () => {
wrapper.update();
expect(mockedProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"filters": Array [
Object {
"Header": "name",
"id": "name",
"operator": "sw",
"value": "foo",
},
],
"pageIndex": 0,
"pageSize": 1,
"sortBy": Array [
Object {
"desc": false,
"id": "id",
},
],
},
]
`);
Array [
Object {
"filters": Array [
Object {
"id": "name",
"operator": "sw",
"value": "foo",
},
],
"pageIndex": 0,
"pageSize": 1,
"sortBy": Array [
Object {
"desc": false,
"id": "id",
},
],
},
]
`);
});
it('renders pagination controls', () => {
expect(wrapper.find(Pagination).exists()).toBe(true);
expect(wrapper.find(Pagination.Prev).exists()).toBe(true);
expect(wrapper.find(Pagination.Item).exists()).toBe(true);
expect(wrapper.find(Pagination.Next).exists()).toBe(true);
});
it('calls fetchData on page change', () => {
act(() => {
wrapper.find(ListViewPagination).prop('onChange')(2);
@ -173,28 +176,28 @@ describe('ListView', () => {
wrapper.update();
expect(mockedProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"filters": Array [
Object {
"Header": "name",
"id": "name",
"operator": "sw",
"value": "foo",
},
],
"pageIndex": 1,
"pageSize": 1,
"sortBy": Array [
Object {
"desc": false,
"id": "id",
},
],
},
]
`);
Array [
Object {
"filters": Array [
Object {
"id": "name",
"operator": "sw",
"value": "foo",
},
],
"pageIndex": 1,
"pageSize": 1,
"sortBy": Array [
Object {
"desc": false,
"id": "id",
},
],
},
]
`);
});
it('handles bulk actions on 1 row', () => {
act(() => {
wrapper
@ -228,6 +231,7 @@ describe('ListView', () => {
]
`);
});
it('handles bulk actions on all rows', () => {
act(() => {
wrapper
@ -265,6 +269,7 @@ describe('ListView', () => {
]
`);
});
it('Throws an exception if filter missing in columns', () => {
expect.assertions(1);
const props = {
@ -280,3 +285,105 @@ describe('ListView', () => {
}
});
});
describe('ListView with new UI filters', () => {
const newFiltersProps = {
...mockedProps,
useNewUIFilters: true,
filters: [
{
Header: 'ID',
id: 'id',
input: 'select',
selects: [{ label: 'foo', value: 'bar' }],
operator: 'eq',
},
{
Header: 'Name',
id: 'name',
input: 'search',
operator: 'ct',
},
],
};
const wrapper = mount(<ListView {...newFiltersProps} />);
afterEach(() => {
mockedProps.fetchData.mockClear();
mockedProps.bulkActions.forEach(ba => {
ba.onSelect.mockClear();
});
});
it('renders UI filters', () => {
expect(wrapper.find(ListViewFilters)).toHaveLength(1);
});
it('calls fetchData on filter', () => {
act(() => {
wrapper
.find('[data-test="filters-select"]')
.last()
.props()
.onChange({ value: 'bar' });
});
act(() => {
wrapper
.find('[data-test="filters-search"]')
.last()
.props()
.onChange({ currentTarget: { value: 'something' } });
});
wrapper.update();
act(() => {
wrapper
.find('[data-test="filters-search"]')
.last()
.props()
.onBlur();
});
expect(newFiltersProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
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 [],
},
]
`);
});
});

View File

@ -0,0 +1,168 @@
/**
* 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 styled from '@emotion/styled';
import { withTheme } from 'emotion-theming';
import StyledSelect from 'src/components/StyledSelect';
import SearchInput from 'src/components/SearchInput';
import { Filter, Filters, FilterValue, InternalFilter } from './types';
interface BaseFilter {
Header: string;
initialValue: any;
}
interface SelectFilterProps extends BaseFilter {
onSelect: (selected: any) => any;
selects: Filter['selects'];
emptyLabel?: string;
}
const FilterContainer = styled.div`
display: inline;
margin-right: 8px;
`;
const Title = styled.span`
font-weight: bold;
`;
const CLEAR_SELECT_FILTER_VALUE = 'CLEAR_SELECT_FILTER_VALUE';
function SelectFilter({
Header,
selects = [],
emptyLabel = 'None',
initialValue,
onSelect,
}: SelectFilterProps) {
const clearFilterSelect = {
label: emptyLabel,
value: CLEAR_SELECT_FILTER_VALUE,
};
const options = React.useMemo(() => [clearFilterSelect, ...selects], [
emptyLabel,
selects,
]);
const [value, setValue] = useState(
typeof initialValue === 'undefined'
? clearFilterSelect.value
: initialValue,
);
const onChange = (selected: { label: string; value: any } | null) => {
if (selected === null) return;
setValue(selected.value);
onSelect(
selected.value === CLEAR_SELECT_FILTER_VALUE ? undefined : selected.value,
);
};
return (
<FilterContainer>
<Title>{Header}:</Title>
<StyledSelect
data-test="filters-select"
value={value}
options={options}
onChange={onChange}
clearable={false}
/>
</FilterContainer>
);
}
interface SearchHeaderProps extends BaseFilter {
Header: string;
onSubmit: (val: string) => void;
}
function SearchFilter({ Header, initialValue, onSubmit }: SearchHeaderProps) {
const [value, setValue] = useState(initialValue || '');
const handleSubmit = () => onSubmit(value);
return (
<FilterContainer>
<SearchInput
data-test="filters-search"
placeholder={Header}
value={value}
onChange={e => {
setValue(e.currentTarget.value);
}}
onKeyDown={e => {
if (e.key === 'Enter') {
handleSubmit();
}
}}
onBlur={handleSubmit}
/>
</FilterContainer>
);
}
interface UIFiltersProps {
filters: Filters;
internalFilters: InternalFilter[];
updateFilterValue: (id: number, value: FilterValue['value']) => void;
}
const FilterWrapper = styled.div`
padding: 24px 16px 8px;
`;
function UIFilters({
filters,
internalFilters = [],
updateFilterValue,
}: UIFiltersProps) {
return (
<FilterWrapper>
{filters.map(({ Header, input, selects, unfilteredLabel }, index) => {
const initialValue =
internalFilters[index] && internalFilters[index].value;
if (input === 'select') {
return (
<SelectFilter
key={Header}
Header={Header}
selects={selects}
emptyLabel={unfilteredLabel}
initialValue={initialValue}
onSelect={(value: any) => updateFilterValue(index, value)}
/>
);
}
if (input === 'search') {
return (
<SearchFilter
key={Header}
Header={Header}
initialValue={initialValue}
onSubmit={(value: string) => updateFilterValue(index, value)}
/>
);
}
return null;
})}
</FilterWrapper>
);
}
export default withTheme(UIFilters);

View File

@ -0,0 +1,199 @@
/**
* 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 { t } from '@superset-ui/translation';
import React, { Dispatch, SetStateAction } from 'react';
import {
Button,
Col,
DropdownButton,
FormControl,
MenuItem,
Row,
// @ts-ignore
} from 'react-bootstrap';
// @ts-ignore
import SelectComponent from 'react-select';
// @ts-ignore
import VirtualizedSelect from 'react-virtualized-select';
import { Filters, InternalFilter, Select } from './types';
import { extractInputValue, getDefaultFilterOperator } from './utils';
export const FilterMenu = ({
filters,
internalFilters,
setInternalFilters,
}: {
filters: Filters;
internalFilters: InternalFilter[];
setInternalFilters: Dispatch<SetStateAction<InternalFilter[]>>;
}) => (
<div className="filter-dropdown">
<DropdownButton
id="filter-picker"
bsSize="small"
bsStyle={'default'}
noCaret
title={
<>
<i className="fa fa-filter text-primary" />
{' '}
{t('Filter List')}
</>
}
>
{filters
.map(({ id, Header }) => ({
Header,
id,
value: undefined,
}))
.map(ft => (
<MenuItem
key={ft.id}
eventKey={ft}
onSelect={(fltr: typeof ft) =>
setInternalFilters([...internalFilters, fltr])
}
>
{ft.Header}
</MenuItem>
))}
</DropdownButton>
</div>
);
export const FilterInputs = ({
internalFilters,
filters,
updateInternalFilter,
removeFilterAndApply,
filtersApplied,
applyFilters,
}: {
internalFilters: InternalFilter[];
filters: Filters;
updateInternalFilter: (i: number, f: object) => void;
removeFilterAndApply: (i: number) => void;
filtersApplied: boolean;
applyFilters: () => void;
}) => (
<>
{internalFilters.map((ft, i) => {
const filter = filters.find(f => f.id === ft.id);
if (!filter) {
console.error(`could not find filter for ${ft.id}`);
return null;
}
return (
<div key={`${ft.Header}-${i}`} className="filter-inputs">
<Row>
<Col className="text-center filter-column" md={2}>
<span>{ft.Header}</span>
</Col>
<Col md={2}>
<FormControl
componentClass="select"
bsSize="small"
value={ft.operator}
placeholder={filter ? getDefaultFilterOperator(filter) : ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
updateInternalFilter(i, {
operator: e.currentTarget.value,
});
}}
>
{(filter.operators || []).map(({ label, value }: Select) => (
<option key={label} value={value}>
{label}
</option>
))}
</FormControl>
</Col>
<Col md={1} />
<Col md={4}>
{filter.input === 'select' && (
<VirtualizedSelect
autoFocus
multi
searchable
name={`filter-${filter.id}-select`}
options={filter.selects}
placeholder="Select Value"
value={ft.value}
selectComponent={SelectComponent}
onChange={(e: Select[] | null) => {
updateInternalFilter(i, {
operator: ft.operator || getDefaultFilterOperator(filter),
value: e ? e.map(s => s.value) : e,
});
}}
/>
)}
{filter.input !== 'select' && (
<FormControl
type={filter.input ? filter.input : 'text'}
bsSize="small"
value={ft.value || ''}
checked={Boolean(ft.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
e.persist();
updateInternalFilter(i, {
operator: ft.operator || getDefaultFilterOperator(filter),
value: extractInputValue(filter.input, e),
});
}}
/>
)}
</Col>
<Col md={1}>
<div
className="filter-close"
role="button"
tabIndex={0}
onClick={() => removeFilterAndApply(i)}
>
<i className="fa fa-close text-primary" />
</div>
</Col>
</Row>
<br />
</div>
);
})}
{internalFilters.length > 0 && (
<>
<Row>
<Col md={10} />
<Col md={2}>
<Button
data-test="apply-filters"
disabled={!!filtersApplied}
bsStyle="primary"
onClick={applyFilters}
bsSize="small"
>
{t('Apply')}
</Button>
</Col>
</Row>
<br />
</>
)}
</>
);

View File

@ -19,10 +19,8 @@
import { t } from '@superset-ui/translation';
import React, { FunctionComponent } from 'react';
import {
Button,
Col,
DropdownButton,
FormControl,
MenuItem,
Row,
// @ts-ignore
@ -34,20 +32,10 @@ import VirtualizedSelect from 'react-virtualized-select';
import IndeterminateCheckbox from '../IndeterminateCheckbox';
import TableCollection from './TableCollection';
import Pagination from './Pagination';
import {
FetchDataConfig,
Filters,
InternalFilter,
Select,
SortColumn,
} from './types';
import {
convertFilters,
extractInputValue,
ListViewError,
removeFromList,
useListViewState,
} from './utils';
import { FilterMenu, FilterInputs } from './LegacyFilters';
import FilterControls from './Filters';
import { FetchDataConfig, Filters, SortColumn } from './types';
import { ListViewError, useListViewState } from './utils';
import './ListViewStyles.less';
@ -67,6 +55,7 @@ interface Props {
name: React.ReactNode;
onSelect: (rows: any[]) => any;
}>;
useNewUIFilters?: boolean;
}
const bulkSelectColumnConfig = {
@ -95,6 +84,7 @@ const ListView: FunctionComponent<Props> = ({
title = '',
filters = [],
bulkActions = [],
useNewUIFilters = false,
}) => {
const {
getTableProps,
@ -104,9 +94,10 @@ const ListView: FunctionComponent<Props> = ({
prepareRow,
pageCount = 1,
gotoPage,
setAllFilters,
removeFilterAndApply,
setInternalFilters,
updateInternalFilter,
applyFilterValue,
applyFilters,
filtersApplied,
selectedFlatRows,
@ -120,6 +111,7 @@ const ListView: FunctionComponent<Props> = ({
fetchData,
initialPageSize,
initialSort,
initialFilters: useNewUIFilters ? filters : [],
});
const filterable = Boolean(filters.length);
if (filterable) {
@ -136,161 +128,56 @@ const ListView: FunctionComponent<Props> = ({
});
}
const removeFilterAndApply = (index: number) => {
const updated = removeFromList(internalFilters, index);
setInternalFilters(updated);
setAllFilters(convertFilters(updated));
};
return (
<div className={`superset-list-view ${className}`}>
{title && filterable && (
<div className="header">
<Row>
<Col md={10}>
<h2>{t(title)}</h2>
</Col>
{filterable && (
<Col md={2}>
<div className="filter-dropdown">
<DropdownButton
id="filter-picker"
bsSize="small"
bsStyle={'default'}
noCaret
title={
<>
<i className="fa fa-filter text-primary" />
{' '}
{t('Filter List')}
</>
}
>
{filters
.map(({ id, Header }) => ({
Header,
id,
}))
.map((ft: InternalFilter) => (
<MenuItem
key={ft.id}
eventKey={ft}
onSelect={(fltr: InternalFilter) =>
setInternalFilters([...internalFilters, fltr])
}
>
{ft.Header}
</MenuItem>
))}
</DropdownButton>
</div>
</Col>
)}
</Row>
<hr />
{internalFilters.map((ft, i) => {
const filter = filters.find(f => f.id === ft.id);
if (!filter) {
console.error(`could not find filter for ${ft.id}`);
return null;
}
return (
<div key={`${ft.Header}-${i}`} className="filter-inputs">
<div className="header">
{!useNewUIFilters && (
<>
{title && filterable && (
<>
<Row>
<Col className="text-center filter-column" md={2}>
<span>{ft.Header}</span>
<Col md={10}>
<h2>{t(title)}</h2>
</Col>
<Col md={2}>
<FormControl
componentClass="select"
bsSize="small"
value={ft.operator}
placeholder={filter ? filter.operators[0] : ''}
onChange={(e: React.MouseEvent<HTMLInputElement>) => {
updateInternalFilter(i, {
operator: e.currentTarget.value,
});
}}
>
{filter.operators.map(({ label, value }: Select) => (
<option key={label} value={value}>
{label}
</option>
))}
</FormControl>
</Col>
<Col md={1} />
<Col md={4}>
{filter.input === 'select' && (
<VirtualizedSelect
autoFocus
multi
searchable
name={`filter-${filter.id}-select`}
options={filter.selects}
placeholder="Select Value"
value={ft.value}
selectComponent={SelectComponent}
onChange={(e: Select[] | null) => {
updateInternalFilter(i, {
operator: ft.operator || filter.operators[0].value,
value: e ? e.map(s => s.value) : e,
});
}}
{filterable && (
<Col md={2}>
<FilterMenu
filters={filters}
internalFilters={internalFilters}
setInternalFilters={setInternalFilters}
/>
)}
{filter.input !== 'select' && (
<FormControl
type={filter.input ? filter.input : 'text'}
bsSize="small"
value={ft.value || ''}
checked={Boolean(ft.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
e.persist();
updateInternalFilter(i, {
operator: ft.operator || filter.operators[0].value,
value: extractInputValue(filter.input, e),
});
}}
/>
)}
</Col>
<Col md={1}>
<div
className="filter-close"
role="button"
tabIndex={0}
onClick={() => removeFilterAndApply(i)}
>
<i className="fa fa-close text-primary" />
</div>
</Col>
</Col>
)}
</Row>
<br />
</div>
);
})}
{internalFilters.length > 0 && (
<>
<Row>
<Col md={10} />
<Col md={2}>
<Button
data-test="apply-filters"
disabled={!!filtersApplied}
bsStyle="primary"
onClick={applyFilters}
bsSize="small"
>
{t('Apply')}
</Button>
</Col>
</Row>
<br />
</>
)}
</div>
)}
<hr />
<FilterInputs
internalFilters={internalFilters}
filters={filters}
updateInternalFilter={updateInternalFilter}
removeFilterAndApply={removeFilterAndApply}
filtersApplied={filtersApplied}
applyFilters={applyFilters}
/>
</>
)}
</>
)}
{useNewUIFilters && (
<>
<Row>
<Col md={10}>
<h2>{t(title)}</h2>
</Col>
</Row>
<hr />
<FilterControls
filters={filters}
internalFilters={internalFilters}
updateFilterValue={applyFilterValue}
/>
</>
)}
</div>
<div className="body">
<TableCollection
getTableProps={getTableProps}

View File

@ -31,8 +31,10 @@ export interface Select {
export interface Filter {
Header: string;
id: string;
operators: Select[];
input?: 'text' | 'textarea' | 'select' | 'checkbox';
operators?: Select[];
operator?: string;
input?: 'text' | 'textarea' | 'select' | 'checkbox' | 'search';
unfilteredLabel?: string;
selects?: Select[];
}
@ -41,7 +43,7 @@ export type Filters = Filter[];
export interface FilterValue {
id: string;
operator?: string;
value: string | boolean | number;
value: string | boolean | number | null | undefined;
}
export interface FetchDataConfig {
@ -52,7 +54,7 @@ export interface FetchDataConfig {
}
export interface InternalFilter extends FilterValue {
Header: string;
Header?: string;
}
export interface FilterOperatorMap {

View File

@ -33,7 +33,13 @@ import {
useQueryParams,
} from 'use-query-params';
import { FetchDataConfig, InternalFilter, SortColumn } from './types';
import {
FetchDataConfig,
Filter,
FilterValue,
InternalFilter,
SortColumn,
} from './types';
export class ListViewError extends Error {
name = 'ListViewError';
@ -55,17 +61,22 @@ function updateInList(list: any[], index: number, update: any): any[] {
];
}
// convert filters from UI objects to data objects
export function convertFilters(fts: InternalFilter[]) {
return fts
.filter((ft: InternalFilter) => ft.value)
.map(ft => ({ operator: ft.operator, ...ft }));
function mergeCreateFilterValues(list: Filter[], updateList: FilterValue[]) {
return list.map(({ id, operator }) => {
const update = updateList.find(obj => obj.id === id);
return { id, operator, value: update?.value };
});
}
export function extractInputValue(
inputType: 'text' | 'textarea' | 'checkbox' | 'select' | undefined,
event: any,
) {
// convert filters from UI objects to data objects
export function convertFilters(fts: InternalFilter[]): FilterValue[] {
return fts
.filter(f => typeof f.value !== 'undefined')
.map(({ value, operator, id }) => ({ value, operator, id }));
}
export function extractInputValue(inputType: Filter['input'], event: any) {
if (!inputType || inputType === 'text') {
return event.currentTarget.value;
}
@ -76,6 +87,13 @@ export function extractInputValue(
return null;
}
export function getDefaultFilterOperator(filter: Filter): string {
if (filter?.operator) return filter.operator;
if (filter?.operators?.length) {
return filter.operators[0].value;
}
return '';
}
interface UseListViewConfig {
fetchData: (conf: FetchDataConfig) => any;
columns: any[];
@ -84,6 +102,7 @@ interface UseListViewConfig {
initialPageSize: number;
initialSort?: SortColumn[];
bulkSelectMode?: boolean;
initialFilters?: Filter[];
bulkSelectColumnConfig?: {
id: string;
Header: (conf: any) => React.ReactNode;
@ -97,6 +116,7 @@ export function useListViewState({
data,
count,
initialPageSize,
initialFilters = [],
initialSort = [],
bulkSelectMode = false,
bulkSelectColumnConfig,
@ -123,10 +143,13 @@ export function useListViewState({
sortBy: initialSortBy,
};
const columnsWithSelect = useMemo(
() => (bulkSelectMode ? [bulkSelectColumnConfig, ...columns] : columns),
[bulkSelectMode, columns],
);
const columnsWithSelect = useMemo(() => {
// add exact filter type so filters with falsey values are not filtered out
const columnsWithFilter = columns.map(f => ({ ...f, filter: 'exact' }));
return bulkSelectMode
? [bulkSelectColumnConfig, ...columnsWithFilter]
: columnsWithFilter;
}, [bulkSelectMode, columns]);
const {
getTableProps,
@ -165,6 +188,14 @@ export function useListViewState({
query.filters || [],
);
useEffect(() => {
if (initialFilters.length) {
setInternalFilters(
mergeCreateFilterValues(initialFilters, query.filters),
);
}
}, [initialFilters]);
useEffect(() => {
const queryParams: any = {
filters: internalFilters,
@ -175,22 +206,41 @@ export function useListViewState({
queryParams.sortOrder = sortBy[0].desc ? 'desc' : 'asc';
}
setQuery(queryParams);
fetchData({ pageIndex, pageSize, sortBy, filters });
}, [fetchData, pageIndex, pageSize, sortBy, filters]);
const filtersApplied = internalFilters.every(
({ id, value, operator }, index) =>
id &&
filters[index] &&
filters[index].id === id &&
filters[index].value === value &&
filters[index]?.id === id &&
filters[index]?.value === value &&
// @ts-ignore
filters[index].operator === operator,
filters[index]?.operator === operator,
);
const updateInternalFilter = (index: number, update: object) =>
setInternalFilters(updateInList(internalFilters, index, update));
const applyFilterValue = (index: number, value: any) => {
// skip redunundant updates
if (internalFilters[index].value === value) {
return;
}
const update = { ...internalFilters[index], value };
const updatedFilters = updateInList(internalFilters, index, update);
setInternalFilters(updatedFilters);
setAllFilters(convertFilters(updatedFilters));
};
const removeFilterAndApply = (index: number) => {
const updated = removeFromList(internalFilters, index);
setInternalFilters(updated);
setAllFilters(convertFilters(updated));
};
return {
applyFilters: () => setAllFilters(convertFilters(internalFilters)),
removeFilterAndApply,
canNextPage,
canPreviousPage,
filtersApplied,
@ -205,7 +255,7 @@ export function useListViewState({
setAllFilters,
setInternalFilters,
state: { pageIndex, pageSize, sortBy, filters, internalFilters },
updateInternalFilter: (index: number, update: object) =>
setInternalFilters(updateInList(internalFilters, index, update)),
updateInternalFilter,
applyFilterValue,
};
}

View File

@ -0,0 +1,29 @@
/**
* 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 from '@emotion/styled';
export default styled.input`
background-color: #fff;
background-image: none;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
padding: 4px 8px;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
`;

View File

@ -0,0 +1,48 @@
/**
* 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 from '@emotion/styled';
// @ts-ignore
import Select from 'react-select';
export default styled(Select)`
display: inline;
&.is-focused:not(.is-open) > .Select-control {
border: none;
box-shadow: none;
}
.Select-control {
display: inline-table;
border: none;
width: 100px;
&:focus,
&:hover {
border: none;
box-shadow: none;
}
.Select-arrow-zone {
padding-left: 10px;
}
}
.Select-menu-outer {
margin-top: 0;
border-bottom-left-radius: 0;
border-bottom-left-radius: 0;
}
`;

View File

@ -26,6 +26,7 @@ export enum FeatureFlag {
ESTIMATE_QUERY_COST = 'ESTIMATE_QUERY_COST',
SHARE_QUERIES_VIA_KV_STORE = 'SHARE_QUERIES_VIA_KV_STORE',
SQLLAB_BACKEND_PERSISTENCE = 'SQLLAB_BACKEND_PERSISTENCE',
LIST_VIEWS_NEW_UI = 'LIST_VIEWS_NEW_UI',
}
export type FeatureFlagMap = {

View File

@ -68,7 +68,6 @@ import {
declare module 'react-table' {
export interface TableOptions<D extends object>
extends UseExpandedOptions<D>,
UseFiltersOptions<D>,
UseFiltersOptions<D>,
UseGlobalFiltersOptions<D>,
UseGroupByOptions<D>,

View File

@ -32,6 +32,7 @@ import {
} from 'src/components/ListView/types';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
const PAGE_SIZE = 25;
@ -125,6 +126,10 @@ class DashboardList extends React.PureComponent<Props, State> {
return this.hasPerm('can_mulexport');
}
get isNewUIEnabled() {
return isFeatureEnabled(FeatureFlag.LIST_VIEWS_NEW_UI);
}
initialSort = [{ id: 'changed_on', desc: true }];
columns = [
@ -378,6 +383,39 @@ class DashboardList extends React.PureComponent<Props, State> {
updateFilters = () => {
const { filterOperators, owners } = this.state;
if (this.isNewUIEnabled) {
return this.setState({
filters: [
{
Header: 'Owner',
id: 'owners',
input: 'select',
operator: 'rel_m_m',
unfilteredLabel: 'All',
selects: owners.map(({ text: label, value }) => ({ label, value })),
},
{
Header: 'Published',
id: 'published',
input: 'select',
operator: 'eq',
unfilteredLabel: 'Any',
selects: [
{ label: 'Published', value: true },
{ label: 'Unpublished', value: false },
],
},
{
Header: 'Search',
id: 'dashboard_title',
input: 'search',
operator: 'title_or_slug',
},
],
});
}
const convertFilter = ({
name: label,
operator,
@ -386,7 +424,7 @@ class DashboardList extends React.PureComponent<Props, State> {
operator: string;
}) => ({ label, value: operator });
this.setState({
return this.setState({
filters: [
{
Header: 'Dashboard',
@ -481,6 +519,7 @@ class DashboardList extends React.PureComponent<Props, State> {
initialSort={this.initialSort}
filters={filters}
bulkActions={bulkActions}
useNewUIFilters={this.isNewUIEnabled}
/>
</>
);

View File

@ -22,7 +22,10 @@ import thunk from 'redux-thunk';
import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
import { Provider } from 'react-redux';
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
import { ThemeProvider } from 'emotion-theming';
import { initFeatureFlags } from 'src/featureFlags';
import { supersetTheme } from 'stylesheets/styled-components/superset-theme';
import Menu from 'src/components/Menu/Menu';
import DashboardList from 'src/views/dashboardList/DashboardList';
import ChartList from 'src/views/chartList/ChartList';
@ -41,6 +44,8 @@ const bootstrap = JSON.parse(container.getAttribute('data-bootstrap'));
const user = { ...bootstrap.user };
const menu = { ...bootstrap.common.menu_data };
initFeatureFlags(bootstrap.common.feature_flags);
const store = createStore(
combineReducers({
messageToasts: messageToastReducer,
@ -51,24 +56,26 @@ const store = createStore(
const App = () => (
<Provider store={store}>
<Router>
<Menu data={menu} />
<Switch>
<Route path="/superset/welcome/">
<Welcome user={user} />
</Route>
<Route path="/dashboard/list/">
<DashboardList user={user} />
</Route>
<Route path="/chart/list/">
<ChartList user={user} />
</Route>
<Route path="/tablemodelview/list/">
<DatasetList user={user} />
</Route>
</Switch>
<ToastPresenter />
</Router>
<ThemeProvider theme={supersetTheme}>
<Router>
<Menu data={menu} />
<Switch>
<Route path="/superset/welcome/">
<Welcome user={user} />
</Route>
<Route path="/dashboard/list/">
<DashboardList user={user} />
</Route>
<Route path="/chart/list/">
<ChartList user={user} />
</Route>
<Route path="/tablemodelview/list/">
<DatasetList user={user} />
</Route>
</Switch>
<ToastPresenter />
</Router>
</ThemeProvider>
</Provider>
);

View File

@ -0,0 +1,44 @@
/**
* 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, { CreateStyled } from '@emotion/styled';
const defaultTheme = {
borderRadius: '4px',
colors: {
primary: {
base: '#20A7C9',
},
secondary: {
base: '#444E7C',
dark1: '#363E63',
dark2: '#282E4A',
dark3: '#1B1F31',
light1: '#8E94B0',
light2: '#B4B8CA',
light3: '#D9DBE4',
light4: '#ECEEF2',
light5: '#F5F5F8',
},
},
gridUnit: '4px',
};
export default styled as CreateStyled<typeof defaultTheme>;
export const supersetTheme = defaultTheme;

View File

@ -154,6 +154,7 @@ const babelLoader = {
// disable gzip compression for cache files
// faster when there are millions of small files
cacheCompression: false,
plugins: ['emotion'],
},
};
@ -198,6 +199,7 @@ const config = {
alias: {
src: path.resolve(APP_DIR, './src'),
'react-dom': '@hot-loader/react-dom',
stylesheets: path.resolve(APP_DIR, './stylesheets'),
},
extensions: ['.ts', '.tsx', '.js', '.jsx'],
symlinks: false,

View File

@ -289,6 +289,7 @@ DEFAULT_FEATURE_FLAGS = {
"SHARE_QUERIES_VIA_KV_STORE": False,
"TAGGING_SYSTEM": False,
"SQLLAB_BACKEND_PERSISTENCE": False,
"LIST_VIEWS_NEW_UI": False,
}
# This is merely a default.