mirror of
https://github.com/ansible/awx.git
synced 2026-05-07 17:37:37 -02:30
add support for number, boolean, and option-based searches
This commit is contained in:
@@ -47,6 +47,7 @@ class DataListToolbar extends React.Component {
|
|||||||
isCompact,
|
isCompact,
|
||||||
onSort,
|
onSort,
|
||||||
onSearch,
|
onSearch,
|
||||||
|
onReplaceSearch,
|
||||||
onRemove,
|
onRemove,
|
||||||
onCompact,
|
onCompact,
|
||||||
onExpand,
|
onExpand,
|
||||||
@@ -78,6 +79,7 @@ class DataListToolbar extends React.Component {
|
|||||||
qsConfig={qsConfig}
|
qsConfig={qsConfig}
|
||||||
columns={searchColumns}
|
columns={searchColumns}
|
||||||
onSearch={onSearch}
|
onSearch={onSearch}
|
||||||
|
onReplaceSearch={onReplaceSearch}
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
/>
|
/>
|
||||||
</DataToolbarItem>
|
</DataToolbarItem>
|
||||||
@@ -124,6 +126,7 @@ DataListToolbar.propTypes = {
|
|||||||
onCompact: PropTypes.func,
|
onCompact: PropTypes.func,
|
||||||
onExpand: PropTypes.func,
|
onExpand: PropTypes.func,
|
||||||
onSearch: PropTypes.func,
|
onSearch: PropTypes.func,
|
||||||
|
onReplaceSearch: PropTypes.func,
|
||||||
onSelectAll: PropTypes.func,
|
onSelectAll: PropTypes.func,
|
||||||
onSort: PropTypes.func,
|
onSort: PropTypes.func,
|
||||||
additionalControls: PropTypes.arrayOf(PropTypes.node),
|
additionalControls: PropTypes.arrayOf(PropTypes.node),
|
||||||
@@ -136,6 +139,7 @@ DataListToolbar.defaultProps = {
|
|||||||
onCompact: null,
|
onCompact: null,
|
||||||
onExpand: null,
|
onExpand: null,
|
||||||
onSearch: null,
|
onSearch: null,
|
||||||
|
onReplaceSearch: null,
|
||||||
onSelectAll: null,
|
onSelectAll: null,
|
||||||
onSort: null,
|
onSort: null,
|
||||||
additionalControls: [],
|
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);
|
super(props);
|
||||||
|
|
||||||
this.handleSearch = this.handleSearch.bind(this);
|
this.handleSearch = this.handleSearch.bind(this);
|
||||||
|
this.handleReplaceSearch = this.handleReplaceSearch.bind(this);
|
||||||
this.handleSort = this.handleSort.bind(this);
|
this.handleSort = this.handleSort.bind(this);
|
||||||
this.handleRemove = this.handleRemove.bind(this);
|
this.handleRemove = this.handleRemove.bind(this);
|
||||||
this.handleRemoveAll = this.handleRemoveAll.bind(this);
|
this.handleRemoveAll = this.handleRemoveAll.bind(this);
|
||||||
@@ -41,9 +42,18 @@ class ListHeader extends React.Component {
|
|||||||
this.pushHistoryState(mergeParams(oldParams, { [key]: value }));
|
this.pushHistoryState(mergeParams(oldParams, { [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRemove(key, value) {
|
handleReplaceSearch(key, value) {
|
||||||
const { location, qsConfig } = this.props;
|
const { location, qsConfig } = this.props;
|
||||||
const oldParams = parseQueryString(qsConfig, location.search);
|
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 }));
|
this.pushHistoryState(removeParams(qsConfig, oldParams, { [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +114,7 @@ class ListHeader extends React.Component {
|
|||||||
searchColumns,
|
searchColumns,
|
||||||
sortColumns,
|
sortColumns,
|
||||||
onSearch: this.handleSearch,
|
onSearch: this.handleSearch,
|
||||||
|
onReplaceSearch: this.handleReplaceSearch,
|
||||||
onSort: this.handleSort,
|
onSort: this.handleSort,
|
||||||
onRemove: this.handleRemove,
|
onRemove: this.handleRemove,
|
||||||
qsConfig,
|
qsConfig,
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ OptionsList.propTypes = {
|
|||||||
value: arrayOf(Item).isRequired,
|
value: arrayOf(Item).isRequired,
|
||||||
options: arrayOf(Item).isRequired,
|
options: arrayOf(Item).isRequired,
|
||||||
optionCount: number.isRequired,
|
optionCount: number.isRequired,
|
||||||
searchColumns: SearchColumns.isRequired,
|
searchColumns: SearchColumns,
|
||||||
sortColumns: SortColumns.isRequired,
|
sortColumns: SortColumns,
|
||||||
multiple: bool,
|
multiple: bool,
|
||||||
qsConfig: QSConfig.isRequired,
|
qsConfig: QSConfig.isRequired,
|
||||||
selectItem: func.isRequired,
|
selectItem: func.isRequired,
|
||||||
@@ -91,6 +91,8 @@ OptionsList.propTypes = {
|
|||||||
OptionsList.defaultProps = {
|
OptionsList.defaultProps = {
|
||||||
multiple: false,
|
multiple: false,
|
||||||
renderItemChip: null,
|
renderItemChip: null,
|
||||||
|
searchColumns: [],
|
||||||
|
sortColumns: []
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withI18n()(OptionsList);
|
export default withI18n()(OptionsList);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { Fragment } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
DropdownToggle,
|
DropdownToggle,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
InputGroup,
|
InputGroup,
|
||||||
|
Select,
|
||||||
|
SelectOption,
|
||||||
|
SelectVariant,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +44,7 @@ class Search extends React.Component {
|
|||||||
isSearchDropdownOpen: false,
|
isSearchDropdownOpen: false,
|
||||||
searchKey: columns.find(col => col.isDefault).key,
|
searchKey: columns.find(col => col.isDefault).key,
|
||||||
searchValue: '',
|
searchValue: '',
|
||||||
|
isFilterDropdownOpen: false
|
||||||
};
|
};
|
||||||
|
|
||||||
this.handleSearchInputChange = this.handleSearchInputChange.bind(this);
|
this.handleSearchInputChange = this.handleSearchInputChange.bind(this);
|
||||||
@@ -48,6 +52,9 @@ class Search extends React.Component {
|
|||||||
this.handleDropdownSelect = this.handleDropdownSelect.bind(this);
|
this.handleDropdownSelect = this.handleDropdownSelect.bind(this);
|
||||||
this.handleSearch = this.handleSearch.bind(this);
|
this.handleSearch = this.handleSearch.bind(this);
|
||||||
this.handleTextKeyDown = this.handleTextKeyDown.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) {
|
handleDropdownToggle(isSearchDropdownOpen) {
|
||||||
@@ -73,8 +80,6 @@ class Search extends React.Component {
|
|||||||
qsConfig.integerFields.filter(field => field === searchKey).length ||
|
qsConfig.integerFields.filter(field => field === searchKey).length ||
|
||||||
qsConfig.dateFields.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
|
const actualSearchKey = isNonStringField
|
||||||
? searchKey
|
? searchKey
|
||||||
: `${searchKey}__icontains`;
|
: `${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() {
|
render() {
|
||||||
const { up } = DropdownPosition;
|
const { up } = DropdownPosition;
|
||||||
const { columns, i18n, onRemove, qsConfig, location } = this.props;
|
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(
|
const { name: searchColumnName } = columns.find(
|
||||||
({ key }) => key === searchKey
|
({ key }) => key === searchKey
|
||||||
);
|
);
|
||||||
@@ -129,21 +153,20 @@ class Search extends React.Component {
|
|||||||
|
|
||||||
nonDefaultParams.forEach(key => {
|
nonDefaultParams.forEach(key => {
|
||||||
const columnKey = key
|
const columnKey = key
|
||||||
.replace('__icontains', '');
|
|
||||||
const label = key
|
|
||||||
.replace('__icontains', '')
|
.replace('__icontains', '')
|
||||||
.split('_')
|
.replace('or__', '');
|
||||||
.map(word => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
|
const label = columns
|
||||||
.join(' ');
|
.filter(({key: keyToCheck}) => columnKey === keyToCheck).length ? columns
|
||||||
|
.filter(({key: keyToCheck}) => columnKey === keyToCheck)[0].name : columnKey;
|
||||||
|
|
||||||
queryParamsByKey[columnKey] = { key, label, chips: [] };
|
queryParamsByKey[columnKey] = { key, label, chips: [] };
|
||||||
|
|
||||||
if (Array.isArray(queryParams[key])) {
|
if (Array.isArray(queryParams[key])) {
|
||||||
queryParams[key].forEach(val =>
|
queryParams[key].forEach(val =>
|
||||||
queryParamsByKey[columnKey].chips.push(val)
|
queryParamsByKey[columnKey].chips.push(val.toString())
|
||||||
);
|
);
|
||||||
} else {
|
} 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%' }}
|
style={{ width: '100%' }}
|
||||||
/>) : (<NoOptionDropdown>{searchColumnName}</NoOptionDropdown>)}
|
/>) : (<NoOptionDropdown>{searchColumnName}</NoOptionDropdown>)}
|
||||||
</DataToolbarItem>
|
</DataToolbarItem>
|
||||||
{columns.map(({key}) => (<DataToolbarFilter
|
{columns.map(({ key, name, options, isBoolean }) => (<DataToolbarFilter
|
||||||
chips={chipsByKey[key] ? chipsByKey[key].chips : []}
|
chips={chipsByKey[key] ? chipsByKey[key].chips : []}
|
||||||
deleteChip={(unusedKey, val) => { onRemove(chipsByKey[key].key, val) }}
|
deleteChip={(unusedKey, val) => { onRemove(chipsByKey[key].key, val) }}
|
||||||
categoryName={chipsByKey[key] ? chipsByKey[key].label : key}
|
categoryName={chipsByKey[key] ? chipsByKey[key].label : key}
|
||||||
key={key}
|
key={key}
|
||||||
showToolbarItem={searchKey === 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
|
<TextInput
|
||||||
type="search"
|
type={qsConfig.integerFields.filter(field => field === key).length && "number" || "search"}
|
||||||
aria-label={i18n._(t`Search text input`)}
|
aria-label={i18n._(t`Search text input`)}
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onChange={this.handleSearchInputChange}
|
onChange={this.handleSearchInputChange}
|
||||||
@@ -196,7 +255,7 @@ class Search extends React.Component {
|
|||||||
>
|
>
|
||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</InputGroup>
|
</InputGroup>)}
|
||||||
</DataToolbarFilter>))}
|
</DataToolbarFilter>))}
|
||||||
</DataToolbarGroup>
|
</DataToolbarGroup>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user