add support for number, boolean, and option-based searches

This commit is contained in:
John Mitchell 2019-12-16 15:54:55 -05:00
parent a31661ce08
commit 6edd879a43
7 changed files with 94 additions and 158 deletions

View File

@ -47,6 +47,7 @@ class DataListToolbar extends React.Component {
isCompact,
onSort,
onSearch,
onReplaceSearch,
onRemove,
onCompact,
onExpand,
@ -78,6 +79,7 @@ class DataListToolbar extends React.Component {
qsConfig={qsConfig}
columns={searchColumns}
onSearch={onSearch}
onReplaceSearch={onReplaceSearch}
onRemove={onRemove}
/>
</DataToolbarItem>
@ -124,6 +126,7 @@ DataListToolbar.propTypes = {
onCompact: PropTypes.func,
onExpand: PropTypes.func,
onSearch: PropTypes.func,
onReplaceSearch: PropTypes.func,
onSelectAll: PropTypes.func,
onSort: PropTypes.func,
additionalControls: PropTypes.arrayOf(PropTypes.node),
@ -136,6 +139,7 @@ DataListToolbar.defaultProps = {
onCompact: null,
onExpand: null,
onSearch: null,
onReplaceSearch: null,
onSelectAll: null,
onSort: null,
additionalControls: [],

View File

@ -1,88 +0,0 @@
import React, { Fragment } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { DataToolbarGroup, DataToolbarItem } from '@patternfly/react-core/dist/esm/experimental';
import { parseQueryString } from '@util/qs';
import { Button, Chip, ChipGroup, ChipGroupToolbarItem } from '@patternfly/react-core';
import VerticalSeparator from '@components/VerticalSeparator';
const ResultCount = styled.span`
font-weight: bold;
`;
const FilterLabel = styled.span`
padding-right: 20px;
`;
// remove non-default query params so they don't show up as filter tags
const filterDefaultParams = (paramsArr, config) => {
const defaultParamsKeys = Object.keys(config.defaultParams);
return paramsArr.filter(key => defaultParamsKeys.indexOf(key) === -1);
};
const FilterTags = ({
i18n,
itemCount,
qsConfig,
location,
onRemove,
onRemoveAll,
}) => {
const queryParams = parseQueryString(qsConfig, location.search);
const queryParamsByKey = {};
const nonDefaultParams = filterDefaultParams(
Object.keys(queryParams),
qsConfig
);
nonDefaultParams.forEach(key => {
const label = key
.replace('__icontains', '')
.split('_')
.map(word => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
.join(' ');
queryParamsByKey[key] = { label, tags: [] };
if (Array.isArray(queryParams[key])) {
queryParams[key].forEach(val =>
queryParamsByKey[key].tags.push(val)
);
} else {
queryParamsByKey[key].tags.push(queryParams[key]);
}
});
return (
Object.keys(queryParamsByKey).length > 0 && (
<Fragment>
<DataToolbarGroup>
<ResultCount>{i18n._(t`${itemCount} results`)}</ResultCount>
</DataToolbarGroup>
<DataToolbarGroup>
<FilterLabel>{i18n._(t`Active Filters:`)}</FilterLabel>
<DataToolbarItem variant="chip-group">
{Object.keys(queryParamsByKey).map(key => (
<ChipGroup withToolbar key={`${key}-group`}>
<ChipGroupToolbarItem key={key} categoryName={queryParamsByKey[key].label}>
{queryParamsByKey[key].tags.map(chip => (
<Chip key={chip} onClick={() => onRemove(key, chip)}>
{chip}
</Chip>
))}
</ChipGroupToolbarItem>
</ChipGroup>
))}
</DataToolbarItem>
<DataToolbarItem>
<Button variant="link" onClick={onRemoveAll} isInline>
{i18n._(t`Clear all search filters`)}
</Button>
</DataToolbarItem>
</DataToolbarGroup>
</Fragment>
)
);
};
export default withI18n()(withRouter(FilterTags));

View File

@ -1,51 +0,0 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import FilterTags from './FilterTags';
describe('<ExpandCollapse />', () => {
const qsConfig = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: 'name' },
integerFields: [],
};
const onRemoveFn = jest.fn();
const onRemoveAllFn = jest.fn();
test('initially renders without crashing', () => {
const wrapper = mountWithContexts(
<FilterTags
qsConfig={qsConfig}
onRemove={onRemoveFn}
onRemoveAll={onRemoveAllFn}
/>
);
expect(wrapper.length).toBe(1);
wrapper.unmount();
});
test('renders non-default param tags based on location history', () => {
const history = createMemoryHistory({
initialEntries: [
'/foo?item.page=1&item.page_size=2&item.name__icontains=bar&item.job_type__icontains=project',
],
});
const wrapper = mountWithContexts(
<FilterTags
qsConfig={qsConfig}
onRemove={onRemoveFn}
onRemoveAll={onRemoveAllFn}
/>,
{
context: { router: { history, route: { location: history.location } } },
}
);
const chips = wrapper.find('.pf-c-chip.searchTagChip');
expect(chips.length).toBe(2);
const chipLabels = wrapper.find('.pf-c-chip__text b');
expect(chipLabels.length).toBe(2);
expect(chipLabels.at(0).text()).toEqual('Name:');
expect(chipLabels.at(1).text()).toEqual('Job Type:');
wrapper.unmount();
});
});

View File

@ -1 +0,0 @@
export { default } from './FilterTags';

View File

@ -30,6 +30,7 @@ class ListHeader extends React.Component {
super(props);
this.handleSearch = this.handleSearch.bind(this);
this.handleReplaceSearch = this.handleReplaceSearch.bind(this);
this.handleSort = this.handleSort.bind(this);
this.handleRemove = this.handleRemove.bind(this);
this.handleRemoveAll = this.handleRemoveAll.bind(this);
@ -41,9 +42,18 @@ class ListHeader extends React.Component {
this.pushHistoryState(mergeParams(oldParams, { [key]: value }));
}
handleRemove(key, value) {
handleReplaceSearch(key, value) {
const { location, qsConfig } = this.props;
const oldParams = parseQueryString(qsConfig, location.search);
this.pushHistoryState(replaceParams(oldParams, { [key]: value }));
}
handleRemove(key, value) {
const { location, qsConfig } = this.props;
let oldParams = parseQueryString(qsConfig, location.search);
if (parseInt(value, 10)) {
oldParams = removeParams(qsConfig, oldParams, { [key]: parseInt(value, 10) });
}
this.pushHistoryState(removeParams(qsConfig, oldParams, { [key]: value }));
}
@ -104,6 +114,7 @@ class ListHeader extends React.Component {
searchColumns,
sortColumns,
onSearch: this.handleSearch,
onReplaceSearch: this.handleReplaceSearch,
onSort: this.handleSort,
onRemove: this.handleRemove,
qsConfig,

View File

@ -80,8 +80,8 @@ OptionsList.propTypes = {
value: arrayOf(Item).isRequired,
options: arrayOf(Item).isRequired,
optionCount: number.isRequired,
searchColumns: SearchColumns.isRequired,
sortColumns: SortColumns.isRequired,
searchColumns: SearchColumns,
sortColumns: SortColumns,
multiple: bool,
qsConfig: QSConfig.isRequired,
selectItem: func.isRequired,
@ -91,6 +91,8 @@ OptionsList.propTypes = {
OptionsList.defaultProps = {
multiple: false,
renderItemChip: null,
searchColumns: [],
sortColumns: []
};
export default withI18n()(OptionsList);

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
@ -11,6 +11,9 @@ import {
DropdownToggle,
DropdownItem,
InputGroup,
Select,
SelectOption,
SelectVariant,
TextInput,
} from '@patternfly/react-core';
import {
@ -41,6 +44,7 @@ class Search extends React.Component {
isSearchDropdownOpen: false,
searchKey: columns.find(col => col.isDefault).key,
searchValue: '',
isFilterDropdownOpen: false
};
this.handleSearchInputChange = this.handleSearchInputChange.bind(this);
@ -48,6 +52,9 @@ class Search extends React.Component {
this.handleDropdownSelect = this.handleDropdownSelect.bind(this);
this.handleSearch = this.handleSearch.bind(this);
this.handleTextKeyDown = this.handleTextKeyDown.bind(this);
this.handleFilterDropdownToggle = this.handleFilterDropdownToggle.bind(this);
this.handleFilterDropdownSelect = this.handleFilterDropdownSelect.bind(this);
this.handleFilterBooleanSelect = this.handleFilterBooleanSelect.bind(this);
}
handleDropdownToggle(isSearchDropdownOpen) {
@ -73,8 +80,6 @@ class Search extends React.Component {
qsConfig.integerFields.filter(field => field === searchKey).length ||
qsConfig.dateFields.filter(field => field === searchKey).length;
// TODO: this will probably become more sophisticated, where date
// fields and string fields are passed to a formatter
const actualSearchKey = isNonStringField
? searchKey
: `${searchKey}__icontains`;
@ -94,10 +99,29 @@ class Search extends React.Component {
}
}
handleFilterDropdownToggle(isFilterDropdownOpen) {
this.setState({ isFilterDropdownOpen });
}
handleFilterDropdownSelect(key, event, actualValue) {
const { onSearch, onRemove } = this.props;
if (event.target.checked) {
onSearch(`or__${key}`, actualValue);
} else {
onRemove(`or__${key}`, actualValue);
}
}
handleFilterBooleanSelect(key, selection) {
const { onReplaceSearch } = this.props;
onReplaceSearch(key, selection);
}
render() {
const { up } = DropdownPosition;
const { columns, i18n, onRemove, qsConfig, location } = this.props;
const { isSearchDropdownOpen, searchKey, searchValue } = this.state;
const { isSearchDropdownOpen, searchKey, searchValue, isFilterDropdownOpen } = this.state;
const { name: searchColumnName } = columns.find(
({ key }) => key === searchKey
);
@ -129,21 +153,20 @@ class Search extends React.Component {
nonDefaultParams.forEach(key => {
const columnKey = key
.replace('__icontains', '');
const label = key
.replace('__icontains', '')
.split('_')
.map(word => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
.join(' ');
.replace('or__', '');
const label = columns
.filter(({key: keyToCheck}) => columnKey === keyToCheck).length ? columns
.filter(({key: keyToCheck}) => columnKey === keyToCheck)[0].name : columnKey;
queryParamsByKey[columnKey] = { key, label, chips: [] };
if (Array.isArray(queryParams[key])) {
queryParams[key].forEach(val =>
queryParamsByKey[columnKey].chips.push(val)
queryParamsByKey[columnKey].chips.push(val.toString())
);
} else {
queryParamsByKey[columnKey].chips.push(queryParams[key]);
queryParamsByKey[columnKey].chips.push(queryParams[key].toString());
}
});
@ -174,16 +197,52 @@ class Search extends React.Component {
style={{ width: '100%' }}
/>) : (<NoOptionDropdown>{searchColumnName}</NoOptionDropdown>)}
</DataToolbarItem>
{columns.map(({key}) => (<DataToolbarFilter
{columns.map(({ key, name, options, isBoolean }) => (<DataToolbarFilter
chips={chipsByKey[key] ? chipsByKey[key].chips : []}
deleteChip={(unusedKey, val) => { onRemove(chipsByKey[key].key, val) }}
categoryName={chipsByKey[key] ? chipsByKey[key].label : key}
key={key}
showToolbarItem={searchKey === key}
>
<InputGroup>
{options && (<Select
variant={SelectVariant.checkbox}
aria-label={name}
onToggle={this.handleFilterDropdownToggle}
onSelect={(event, selection) => this.handleFilterDropdownSelect(key, event, selection)}
selections={chipsByKey[key].chips}
isExpanded={isFilterDropdownOpen}
placeholderText={`Filter by ${name.toLowerCase()}`}
>
{options.map(([optionKey]) => (
<Fragment key={optionKey}>
{ /* TODO: update value to being object
{ actualValue: optionKey, toString: () => label }
currently a pf bug that makes the checked logic
not work with object-based values */ }
<SelectOption key={optionKey} value={optionKey} />
</Fragment>
))}
</Select>) || isBoolean && (
<Select
aria-label={name}
onToggle={this.handleFilterDropdownToggle}
onSelect={(event, selection) => this.handleFilterBooleanSelect(key, selection)}
selections={chipsByKey[key].chips[0]}
isExpanded={isFilterDropdownOpen}
placeholderText={`Filter by ${name.toLowerCase()}`}
>
{ /* TODO: update value to being object
{ actualValue: optionKey, toString: () => label }
currently a pf bug that makes the checked logic
not work with object-based values */ }
<SelectOption key="true" value="true" />
<SelectOption key="false" value="false" />
</Select>
) || (<InputGroup>
{/* TODO: add support for dates:
qsConfig.dateFields.filter(field => field === key).length && "date" */}
<TextInput
type="search"
type={qsConfig.integerFields.filter(field => field === key).length && "number" || "search"}
aria-label={i18n._(t`Search text input`)}
value={searchValue}
onChange={this.handleSearchInputChange}
@ -196,7 +255,7 @@ class Search extends React.Component {
>
<SearchIcon />
</Button>
</InputGroup>
</InputGroup>)}
</DataToolbarFilter>))}
</DataToolbarGroup>
);