mirror of
https://github.com/ansible/awx.git
synced 2026-01-16 04:10:44 -03:30
add support for number, boolean, and option-based searches
This commit is contained in:
parent
a31661ce08
commit
6edd879a43
@ -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: [],
|
||||
|
||||
@ -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));
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -1 +0,0 @@
|
||||
export { default } from './FilterTags';
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user