1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-30 13:55:31 +03:00

Add advanced search to UI

This commit is contained in:
John Mitchell 2020-07-28 14:55:52 -04:00
parent c7dd0bc2b9
commit b46a87209a
10 changed files with 828 additions and 70 deletions

View File

@ -23,6 +23,8 @@ class DataListToolbar extends React.Component {
itemCount,
clearAllFilters,
searchColumns,
searchableKeys,
relatedSearchableKeys,
sortColumns,
showSelectAll,
isAllSelected,
@ -64,7 +66,12 @@ class DataListToolbar extends React.Component {
<ToolbarItem>
<Search
qsConfig={qsConfig}
columns={searchColumns}
columns={[
...searchColumns,
{ name: i18n._(t`Advanced`), key: 'advanced' },
]}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
onSearch={onSearch}
onReplaceSearch={onReplaceSearch}
onRemove={onRemove}
@ -106,6 +113,8 @@ DataListToolbar.propTypes = {
clearAllFilters: PropTypes.func,
qsConfig: QSConfig.isRequired,
searchColumns: SearchColumns.isRequired,
searchableKeys: PropTypes.arrayOf(PropTypes.string),
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
sortColumns: SortColumns.isRequired,
showSelectAll: PropTypes.bool,
isAllSelected: PropTypes.bool,
@ -121,6 +130,8 @@ DataListToolbar.propTypes = {
DataListToolbar.defaultProps = {
itemCount: 0,
searchableKeys: [],
relatedSearchableKeys: [],
clearAllFilters: null,
showSelectAll: false,
isAllSelected: false,

View File

@ -25,7 +25,9 @@ describe('<DataListToolbar />', () => {
const onSelectAll = jest.fn();
test('it triggers the expected callbacks', () => {
const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }];
const searchColumns = [
{ name: 'Name', key: 'name__icontains', isDefault: true },
];
const sortColumns = [{ name: 'Name', key: 'name' }];
const search = 'button[aria-label="Search submit button"]';
const searchTextInput = 'input[aria-label="Search text input"]';
@ -108,7 +110,7 @@ describe('<DataListToolbar />', () => {
searchDropdownToggle.simulate('click');
toolbar.update();
let searchDropdownItems = toolbar.find(searchDropdownMenuItems).children();
expect(searchDropdownItems.length).toBe(1);
expect(searchDropdownItems.length).toBe(2);
const mockedSortEvent = { target: { innerText: 'Bar' } };
searchDropdownItems.at(0).simulate('click', mockedSortEvent);
toolbar = mountWithContexts(
@ -144,7 +146,7 @@ describe('<DataListToolbar />', () => {
toolbar.update();
searchDropdownItems = toolbar.find(searchDropdownMenuItems).children();
expect(searchDropdownItems.length).toBe(1);
expect(searchDropdownItems.length).toBe(2);
const mockedSearchEvent = { target: { innerText: 'Bar' } };
searchDropdownItems.at(0).simulate('click', mockedSearchEvent);
@ -283,4 +285,31 @@ describe('<DataListToolbar />', () => {
const checkbox = toolbar.find('Checkbox');
expect(checkbox.prop('isChecked')).toBe(true);
});
test('always adds advanced item to search column array', () => {
const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }];
const sortColumns = [{ name: 'Name', key: 'name' }];
toolbar = mountWithContexts(
<DataListToolbar
qsConfig={QS_CONFIG}
searchColumns={searchColumns}
sortColumns={sortColumns}
onSearch={onSearch}
onReplaceSearch={onReplaceSearch}
onSort={onSort}
onSelectAll={onSelectAll}
additionalControls={[
<button key="1" id="test" type="button">
click
</button>,
]}
/>
);
const search = toolbar.find('Search');
expect(
search.prop('columns').filter(col => col.key === 'advanced').length
).toBe(1);
});
});

View File

@ -94,6 +94,8 @@ class ListHeader extends React.Component {
emptyStateControls,
itemCount,
searchColumns,
searchableKeys,
relatedSearchableKeys,
sortColumns,
renderToolbar,
qsConfig,
@ -122,6 +124,8 @@ class ListHeader extends React.Component {
itemCount,
searchColumns,
sortColumns,
searchableKeys,
relatedSearchableKeys,
onSearch: this.handleSearch,
onReplaceSearch: this.handleReplaceSearch,
onSort: this.handleSort,
@ -141,12 +145,16 @@ ListHeader.propTypes = {
itemCount: PropTypes.number.isRequired,
qsConfig: QSConfig.isRequired,
searchColumns: SearchColumns.isRequired,
searchableKeys: PropTypes.arrayOf(PropTypes.string),
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
sortColumns: SortColumns.isRequired,
renderToolbar: PropTypes.func,
};
ListHeader.defaultProps = {
renderToolbar: props => <DataListToolbar {...props} />,
searchableKeys: [],
relatedSearchableKeys: [],
};
export default withRouter(ListHeader);

View File

@ -16,7 +16,9 @@ describe('ListHeader', () => {
<ListHeader
itemCount={50}
qsConfig={qsConfig}
searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]}
searchColumns={[
{ name: 'foo', key: 'foo__icontains', isDefault: true },
]}
sortColumns={[{ name: 'foo', key: 'foo' }]}
renderToolbar={renderToolbarFn}
/>
@ -33,7 +35,9 @@ describe('ListHeader', () => {
<ListHeader
itemCount={7}
qsConfig={qsConfig}
searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]}
searchColumns={[
{ name: 'foo', key: 'foo__icontains', isDefault: true },
]}
sortColumns={[{ name: 'foo', key: 'foo' }]}
/>,
{ context: { router: { history } } }
@ -56,7 +60,9 @@ describe('ListHeader', () => {
<ListHeader
itemCount={7}
qsConfig={qsConfig}
searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]}
searchColumns={[
{ name: 'foo', key: 'foo__icontains', isDefault: true },
]}
sortColumns={[{ name: 'foo', key: 'foo' }]}
/>,
{ context: { router: { history } } }
@ -77,7 +83,9 @@ describe('ListHeader', () => {
<ListHeader
itemCount={7}
qsConfig={qsConfig}
searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]}
searchColumns={[
{ name: 'foo', key: 'foo__icontains', isDefault: true },
]}
sortColumns={[{ name: 'foo', key: 'foo' }]}
/>,
{ context: { router: { history } } }
@ -100,7 +108,9 @@ describe('ListHeader', () => {
<ListHeader
itemCount={7}
qsConfig={qsConfig}
searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]}
searchColumns={[
{ name: 'foo', key: 'foo__icontains', isDefault: true },
]}
sortColumns={[{ name: 'foo', key: 'foo' }]}
/>,
{ context: { router: { history } } }

View File

@ -30,26 +30,38 @@ function InstanceGroupsLookup(props) {
} = props;
const {
result: { instanceGroups, count },
result: { instanceGroups, count, actions, relatedSearchFields },
request: fetchInstanceGroups,
error,
isLoading,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
const { data } = await InstanceGroupsAPI.read(params);
const [{ data }, actionsResponse] = await Promise.all([
InstanceGroupsAPI.read(params),
InstanceGroupsAPI.readOptions(),
]);
return {
instanceGroups: data.results,
count: data.count,
actions: actionsResponse.data.actions,
relatedSearchFields: (
actionsResponse?.data?.related_search_fields || []
).map(val => val.slice(0, -8)),
};
}, [history.location]),
{ instanceGroups: [], count: 0 }
{ instanceGroups: [], count: 0, actions: {}, relatedSearchFields: [] }
);
useEffect(() => {
fetchInstanceGroups();
}, [fetchInstanceGroups]);
const relatedSearchableKeys = relatedSearchFields || [];
const searchableKeys = Object.keys(actions?.GET || {}).filter(
key => actions.GET[key].filterable
);
return (
<FormGroup
className={className}
@ -74,12 +86,12 @@ function InstanceGroupsLookup(props) {
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
key: 'name__icontains',
isDefault: true,
},
{
name: i18n._(t`Credential Name`),
key: 'credential__name',
key: 'credential__name__icontains',
},
]}
sortColumns={[
@ -88,6 +100,8 @@ function InstanceGroupsLookup(props) {
key: 'name',
},
]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
multiple={state.multiple}
header={i18n._(t`Instance Groups`)}
name="instanceGroups"

View File

@ -69,6 +69,8 @@ class PaginatedDataList extends React.Component {
qsConfig,
renderItem,
toolbarSearchColumns,
toolbarSearchableKeys,
toolbarRelatedSearchableKeys,
toolbarSortColumns,
pluralizedItemName,
showPageSizeOptions,
@ -151,6 +153,8 @@ class PaginatedDataList extends React.Component {
emptyStateControls={emptyStateControls}
searchColumns={searchColumns}
sortColumns={sortColumns}
searchableKeys={toolbarSearchableKeys}
relatedSearchableKeys={toolbarRelatedSearchableKeys}
qsConfig={qsConfig}
pagination={ToolbarPagination}
/>
@ -193,6 +197,8 @@ PaginatedDataList.propTypes = {
qsConfig: QSConfig.isRequired,
renderItem: PropTypes.func,
toolbarSearchColumns: SearchColumns,
toolbarSearchableKeys: PropTypes.arrayOf(PropTypes.string),
toolbarRelatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
toolbarSortColumns: SortColumns,
showPageSizeOptions: PropTypes.bool,
renderToolbar: PropTypes.func,
@ -205,6 +211,8 @@ PaginatedDataList.defaultProps = {
hasContentLoading: false,
contentError: null,
toolbarSearchColumns: [],
toolbarSearchableKeys: [],
toolbarRelatedSearchableKeys: [],
toolbarSortColumns: [],
pluralizedItemName: 'Items',
showPageSizeOptions: true,

View File

@ -0,0 +1,270 @@
import 'styled-components/macro';
import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Button,
ButtonVariant,
InputGroup,
Select,
SelectOption,
SelectVariant,
TextInput,
} from '@patternfly/react-core';
import { SearchIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
const AdvancedGroup = styled.div`
display: flex;
@media (max-width: 991px) {
display: grid;
grid-gap: var(--pf-c-toolbar__expandable-content--m-expanded--GridRowGap);
}
`;
function AdvancedSearch({
i18n,
onSearch,
searchableKeys,
relatedSearchableKeys,
}) {
// TODO: blocked by pf bug, eventually separate these into two groups in the select
// for now, I'm spreading set to get rid of duplicate keys...when they are grouped
// we might want to revisit that.
const allKeys = [
...new Set([...(searchableKeys || []), ...(relatedSearchableKeys || [])]),
];
const [isPrefixDropdownOpen, setIsPrefixDropdownOpen] = useState(false);
const [isLookupDropdownOpen, setIsLookupDropdownOpen] = useState(false);
const [isKeyDropdownOpen, setIsKeyDropdownOpen] = useState(false);
const [prefixSelection, setPrefixSelection] = useState(null);
const [lookupSelection, setLookupSelection] = useState(null);
const [keySelection, setKeySelection] = useState(null);
const [searchValue, setSearchValue] = useState('');
const handleAdvancedSearch = e => {
// keeps page from fully reloading
e.preventDefault();
if (searchValue) {
const actualPrefix = prefixSelection === 'and' ? null : prefixSelection;
let actualSearchKey;
// TODO: once we are able to group options for the key typeahead, we will
// probably want to be able to which group a key was clicked in for duplicates,
// rather than checking to make sure it's not in both for this appending
// __search logic
if (
relatedSearchableKeys.indexOf(keySelection) > -1 &&
searchableKeys.indexOf(keySelection) === -1 &&
keySelection.indexOf('__') === -1
) {
actualSearchKey = `${keySelection}__search`;
} else {
actualSearchKey = [actualPrefix, keySelection, lookupSelection]
.filter(val => !!val)
.join('__');
}
onSearch(actualSearchKey, searchValue);
setSearchValue('');
}
};
const handleAdvancedTextKeyDown = e => {
if (e.key && e.key === 'Enter') {
handleAdvancedSearch(e);
}
};
return (
<AdvancedGroup>
<Select
aria-label={i18n._(t`Set type select`)}
variant={SelectVariant.typeahead}
typeAheadAriaLabel={i18n._(t`Set type typeahead`)}
onToggle={setIsPrefixDropdownOpen}
onSelect={(event, selection) => setPrefixSelection(selection)}
onClear={() => setPrefixSelection(null)}
selections={prefixSelection}
isOpen={isPrefixDropdownOpen}
placeholderText={i18n._(t`Set type`)}
maxHeight="500px"
>
<SelectOption
key="and"
value={i18n._(t`and`)}
description={i18n._(
t`Returns results that satisfy this one as well as other filters. This is the default set type if nothing is selected.`
)}
/>
<SelectOption
key="or"
value={i18n._(t`or`)}
description={i18n._(
t`Returns results that satisfy this one or any other filters.`
)}
/>
<SelectOption
key="not"
value={i18n._(t`not`)}
description={i18n._(
t`Returns results that have values other than this one as well as other filters.`
)}
/>
<SelectOption
key="or_not"
value={i18n._(t`or/not`)}
description={i18n._(
t`Returns results that have values other than this one or any other filters.`
)}
/>
</Select>
<Select
aria-label={i18n._(t`Key select`)}
variant={SelectVariant.typeahead}
typeAheadAriaLabel={i18n._(t`Key typeahead`)}
onToggle={setIsKeyDropdownOpen}
onSelect={(event, selection) => setKeySelection(selection)}
onClear={() => setKeySelection(null)}
selections={keySelection}
isOpen={isKeyDropdownOpen}
placeholderText={i18n._(t`Key`)}
isCreatable
onCreateOption={setKeySelection}
maxHeight="500px"
>
{allKeys.map((optionKey, i) => (
<SelectOption key={`${i}.${optionKey}`} value={optionKey}>
{optionKey}
</SelectOption>
))}
</Select>
<Select
aria-label={i18n._(t`Lookup select`)}
variant={SelectVariant.typeahead}
typeAheadAriaLabel={i18n._(t`Lookup typeahead`)}
onToggle={setIsLookupDropdownOpen}
onSelect={(event, selection) => setLookupSelection(selection)}
onClear={() => setLookupSelection(null)}
selections={lookupSelection}
isOpen={isLookupDropdownOpen}
placeholderText={i18n._(t`Lookup type`)}
maxHeight="500px"
>
<SelectOption
key="exact"
value="exact"
description={i18n._(
t`Exact match (default lookup if not specified).`
)}
/>
<SelectOption
key="iexact"
value="iexact"
description={i18n._(t`Case-insensitive version of exact.`)}
/>
<SelectOption
key="contains"
value="contains"
description={i18n._(t`Field contains value.`)}
/>
<SelectOption
key="icontains"
value="icontains"
description={i18n._(t`Case-insensitive version of contains`)}
/>
<SelectOption
key="startswith"
value="startswith"
description={i18n._(t`Field starts with value.`)}
/>
<SelectOption
key="istartswith"
value="istartswith"
description={i18n._(t`Case-insensitive version of startswith.`)}
/>
<SelectOption
key="endswith"
value="endswith"
description={i18n._(t`Field ends with value.`)}
/>
<SelectOption
key="iendswith"
value="iendswith"
description={i18n._(t`Case-insensitive version of endswith.`)}
/>
<SelectOption
key="regex"
value="regex"
description={i18n._(t`Field matches the given regular expression.`)}
/>
<SelectOption
key="iregex"
value="iregex"
description={i18n._(t`Case-insensitive version of regex.`)}
/>
<SelectOption
key="gt"
value="gt"
description={i18n._(t`Greater than comparison.`)}
/>
<SelectOption
key="gte"
value="gte"
description={i18n._(t`Greater than or equal to comparison.`)}
/>
<SelectOption
key="lt"
value="lt"
description={i18n._(t`Less than comparison.`)}
/>
<SelectOption
key="lte"
value="lte"
description={i18n._(t`Less than or equal to comparison.`)}
/>
<SelectOption
key="isnull"
value="isnull"
description={i18n._(
t`Check whether the given field or related object is null; expects a boolean value.`
)}
/>
<SelectOption
key="in"
value="in"
description={i18n._(
t`Check whether the given field's value is present in the list provided; expects a comma-separated list of items.`
)}
/>
</Select>
<InputGroup>
<TextInput
type="search"
aria-label={i18n._(t`Advanced search value input`)}
isDisabled={!keySelection}
value={
(!keySelection && i18n._(t`First, select a key`)) || searchValue
}
onChange={setSearchValue}
onKeyDown={handleAdvancedTextKeyDown}
/>
<div css={!searchValue && `cursor:not-allowed`}>
<Button
variant={ButtonVariant.control}
isDisabled={!searchValue}
aria-label={i18n._(t`Search submit button`)}
onClick={handleAdvancedSearch}
>
<SearchIcon />
</Button>
</div>
</InputGroup>
</AdvancedGroup>
);
}
// TODO: prop types
export default withI18n()(AdvancedSearch);

View File

@ -0,0 +1,342 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import AdvancedSearch from './AdvancedSearch';
describe('<AdvancedSearch />', () => {
let wrapper;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
wrapper = mountWithContexts(
<AdvancedSearch
onSearch={jest.fn}
searchableKeys={[]}
relatedSearchableKeys={[]}
/>
);
expect(wrapper.length).toBe(1);
});
test('Remove duplicates from searchableKeys/relatedSearchableKeys list', () => {
wrapper = mountWithContexts(
<AdvancedSearch
onSearch={jest.fn}
searchableKeys={['foo', 'bar']}
relatedSearchableKeys={['bar', 'baz']}
/>
);
wrapper
.find('Select[aria-label="Key select"] SelectToggle')
.simulate('click');
expect(
wrapper.find('Select[aria-label="Key select"] SelectOption')
).toHaveLength(3);
});
test("Don't call onSearch unless a search value is set", () => {
const advancedSearchMock = jest.fn();
wrapper = mountWithContexts(
<AdvancedSearch
onSearch={advancedSearchMock}
searchableKeys={['foo', 'bar']}
relatedSearchableKeys={['bar', 'baz']}
/>
);
wrapper
.find('Select[aria-label="Key select"] SelectToggle')
.simulate('click');
wrapper
.find('Select[aria-label="Key select"] SelectOption')
.at(1)
.simulate('click');
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn });
expect(advancedSearchMock).toBeCalledTimes(0);
act(() => {
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.invoke('onChange')('foo');
});
wrapper.update();
act(() => {
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn });
});
wrapper.update();
expect(advancedSearchMock).toBeCalledTimes(1);
});
test('Disable searchValue input until a key is set', () => {
wrapper = mountWithContexts(
<AdvancedSearch
onSearch={jest.fn}
searchableKeys={[]}
relatedSearchableKeys={[]}
/>
);
expect(
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.prop('isDisabled')
).toBe(true);
act(() => {
wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')(
'foo'
);
});
wrapper.update();
expect(
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.prop('isDisabled')
).toBe(false);
});
test('Strip and__ set type from key', () => {
const advancedSearchMock = jest.fn();
wrapper = mountWithContexts(
<AdvancedSearch
onSearch={advancedSearchMock}
searchableKeys={[]}
relatedSearchableKeys={[]}
/>
);
act(() => {
wrapper.find('Select[aria-label="Set type select"]').invoke('onSelect')(
{},
'and'
);
wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')(
'foo'
);
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.invoke('onChange')('bar');
});
wrapper.update();
act(() => {
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn });
});
wrapper.update();
expect(advancedSearchMock).toBeCalledWith('foo', 'bar');
jest.clearAllMocks();
act(() => {
wrapper.find('Select[aria-label="Set type select"]').invoke('onSelect')(
{},
'or'
);
wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')(
'foo'
);
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.invoke('onChange')('bar');
});
wrapper.update();
act(() => {
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn });
});
wrapper.update();
expect(advancedSearchMock).toBeCalledWith('or__foo', 'bar');
});
test('Add __search lookup to key when applicable', () => {
const advancedSearchMock = jest.fn();
wrapper = mountWithContexts(
<AdvancedSearch
onSearch={advancedSearchMock}
searchableKeys={['foo', 'bar']}
relatedSearchableKeys={['bar', 'baz']}
/>
);
act(() => {
wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')(
'foo'
);
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.invoke('onChange')('bar');
});
wrapper.update();
act(() => {
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn });
});
wrapper.update();
expect(advancedSearchMock).toBeCalledWith('foo', 'bar');
jest.clearAllMocks();
act(() => {
wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')(
'bar'
);
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.invoke('onChange')('bar');
});
wrapper.update();
act(() => {
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn });
});
wrapper.update();
expect(advancedSearchMock).toBeCalledWith('bar', 'bar');
jest.clearAllMocks();
act(() => {
wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')(
'baz'
);
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.invoke('onChange')('bar');
});
wrapper.update();
act(() => {
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn });
});
wrapper.update();
expect(advancedSearchMock).toBeCalledWith('baz__search', 'bar');
});
test('Key should be properly constructed from three typeaheads', () => {
const advancedSearchMock = jest.fn();
wrapper = mountWithContexts(
<AdvancedSearch
onSearch={advancedSearchMock}
searchableKeys={[]}
relatedSearchableKeys={[]}
/>
);
act(() => {
wrapper.find('Select[aria-label="Set type select"]').invoke('onSelect')(
{},
'or'
);
wrapper.find('Select[aria-label="Key select"]').invoke('onSelect')(
{},
'foo'
);
wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')(
{},
'exact'
);
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.invoke('onChange')('bar');
});
wrapper.update();
act(() => {
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn });
});
wrapper.update();
expect(advancedSearchMock).toBeCalledWith('or__foo__exact', 'bar');
});
test('searchValue should clear after onSearch is called', () => {
const advancedSearchMock = jest.fn();
wrapper = mountWithContexts(
<AdvancedSearch
onSearch={advancedSearchMock}
searchableKeys={[]}
relatedSearchableKeys={[]}
/>
);
act(() => {
wrapper.find('Select[aria-label="Set type select"]').invoke('onSelect')(
{},
'or'
);
wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')(
'foo'
);
wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')(
{},
'exact'
);
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.invoke('onChange')('bar');
});
wrapper.update();
act(() => {
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn });
});
wrapper.update();
expect(advancedSearchMock).toBeCalledWith('or__foo__exact', 'bar');
expect(
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.prop('value')
).toBe('');
});
test('typeahead onClear should remove key components', () => {
const advancedSearchMock = jest.fn();
wrapper = mountWithContexts(
<AdvancedSearch
onSearch={advancedSearchMock}
searchableKeys={[]}
relatedSearchableKeys={[]}
/>
);
act(() => {
wrapper.find('Select[aria-label="Set type select"]').invoke('onSelect')(
{},
'or'
);
wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')(
'foo'
);
wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')(
{},
'exact'
);
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.invoke('onChange')('bar');
});
wrapper.update();
act(() => {
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn });
});
wrapper.update();
expect(advancedSearchMock).toBeCalledWith('or__foo__exact', 'bar');
jest.clearAllMocks();
act(() => {
wrapper.find('Select[aria-label="Set type select"]').invoke('onClear')();
wrapper.find('Select[aria-label="Key select"]').invoke('onClear')();
wrapper.find('Select[aria-label="Lookup select"]').invoke('onClear')();
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.invoke('onChange')('baz');
});
wrapper.update();
act(() => {
wrapper
.find('TextInputBase[aria-label="Advanced search value input"]')
.prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn });
});
wrapper.update();
expect(advancedSearchMock).toBeCalledWith('', 'baz');
});
});

View File

@ -24,6 +24,7 @@ import { SearchIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { parseQueryString } from '../../util/qs';
import { QSConfig, SearchColumns } from '../../types';
import AdvancedSearch from './AdvancedSearch';
const NoOptionDropdown = styled.div`
align-self: stretch;
@ -77,19 +78,10 @@ class Search extends React.Component {
e.preventDefault();
const { searchKey, searchValue } = this.state;
const { onSearch, qsConfig } = this.props;
const { onSearch } = this.props;
if (searchValue) {
const isNonStringField =
qsConfig.integerFields.find(field => field === searchKey) ||
qsConfig.dateFields.find(field => field === searchKey);
const actualSearchKey = isNonStringField
? searchKey
: `${searchKey}__icontains`;
onSearch(actualSearchKey, searchValue);
onSearch(searchKey, searchValue);
this.setState({ searchValue: '' });
}
}
@ -112,9 +104,9 @@ class Search extends React.Component {
const { onSearch, onRemove } = this.props;
if (event.target.checked) {
onSearch(`or__${key}`, actualValue);
onSearch(key, actualValue);
} else {
onRemove(`or__${key}`, actualValue);
onRemove(key, actualValue);
}
}
@ -125,7 +117,16 @@ class Search extends React.Component {
render() {
const { up } = DropdownPosition;
const { columns, i18n, onRemove, qsConfig, location } = this.props;
const {
columns,
i18n,
onSearch,
onRemove,
qsConfig,
location,
searchableKeys,
relatedSearchableKeys,
} = this.props;
const {
isSearchDropdownOpen,
searchKey,
@ -172,12 +173,14 @@ class Search extends React.Component {
);
nonDefaultParams.forEach(key => {
const columnKey = key.replace('__icontains', '').replace('or__', '');
const columnKey = key;
const label = columns.filter(
({ key: keyToCheck }) => columnKey === keyToCheck
).length
? columns.filter(({ key: keyToCheck }) => columnKey === keyToCheck)[0]
.name
? `${
columns.find(({ key: keyToCheck }) => columnKey === keyToCheck)
.name
} (${key})`
: columnKey;
queryParamsByKey[columnKey] = { key, label, chips: [] };
@ -196,7 +199,6 @@ class Search extends React.Component {
});
}
});
return queryParamsByKey;
};
@ -238,30 +240,37 @@ class Search extends React.Component {
key={key}
showToolbarItem={searchKey === key}
>
{(options && (
<Fragment>
<Select
variant={SelectVariant.checkbox}
aria-label={name}
onToggle={this.handleFilterDropdownToggle}
onSelect={(event, selection) =>
this.handleFilterDropdownSelect(key, event, selection)
}
selections={chipsByKey[key].chips.map(chip => {
const [, ...value] = chip.key.split(':');
return value.join(':');
})}
isOpen={isFilterDropdownOpen}
placeholderText={`Filter By ${name}`}
>
{options.map(([optionKey, optionLabel]) => (
<SelectOption key={optionKey} value={optionKey}>
{optionLabel}
</SelectOption>
))}
</Select>
</Fragment>
{(key === 'advanced' && (
<AdvancedSearch
onSearch={onSearch}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
/>
)) ||
(options && (
<Fragment>
<Select
variant={SelectVariant.checkbox}
aria-label={name}
onToggle={this.handleFilterDropdownToggle}
onSelect={(event, selection) =>
this.handleFilterDropdownSelect(key, event, selection)
}
selections={chipsByKey[key].chips.map(chip => {
const [, ...value] = chip.key.split(':');
return value.join(':');
})}
isOpen={isFilterDropdownOpen}
placeholderText={`Filter By ${name}`}
>
{options.map(([optionKey, optionLabel]) => (
<SelectOption key={optionKey} value={optionKey}>
{optionLabel}
</SelectOption>
))}
</Select>
</Fragment>
)) ||
(isBoolean && (
<Select
aria-label={name}
@ -312,6 +321,28 @@ class Search extends React.Component {
</ToolbarFilter>
)
)}
{/* Add a ToolbarFilter for any key that doesn't have it's own
search column so the chips show up */}
{Object.keys(chipsByKey)
.filter(val => chipsByKey[val].chips.length > 0)
.filter(val => columns.map(val => val.key).indexOf(val) === -1)
.map(leftoverKey => (
<ToolbarFilter
chips={
chipsByKey[leftoverKey] ? chipsByKey[leftoverKey].chips : []
}
deleteChip={(unusedKey, chip) => {
const [columnKey, ...value] = chip.key.split(':');
onRemove(columnKey, value.join(':'));
}}
categoryName={
chipsByKey[leftoverKey]
? chipsByKey[leftoverKey].label
: leftoverKey
}
key={leftoverKey}
/>
))}
</ToolbarGroup>
);
}

View File

@ -22,7 +22,7 @@ describe('<Search />', () => {
});
test('it triggers the expected callbacks', () => {
const columns = [{ name: 'Name', key: 'name', isDefault: true }];
const columns = [{ name: 'Name', key: 'name__icontains', isDefault: true }];
const searchBtn = 'button[aria-label="Search submit button"]';
const searchTextInput = 'input[aria-label="Search text input"]';
@ -50,7 +50,7 @@ describe('<Search />', () => {
});
test('handleDropdownToggle properly updates state', async () => {
const columns = [{ name: 'Name', key: 'name', isDefault: true }];
const columns = [{ name: 'Name', key: 'name__icontains', isDefault: true }];
const onSearch = jest.fn();
const wrapper = mountWithContexts(
<Toolbar
@ -70,8 +70,8 @@ describe('<Search />', () => {
test('handleDropdownSelect properly updates state', async () => {
const columns = [
{ name: 'Name', key: 'name', isDefault: true },
{ name: 'Description', key: 'description' },
{ name: 'Name', key: 'name__icontains', isDefault: true },
{ name: 'Description', key: 'description__icontains' },
];
const onSearch = jest.fn();
const wrapper = mountWithContexts(
@ -85,17 +85,17 @@ describe('<Search />', () => {
</ToolbarContent>
</Toolbar>
).find('Search');
expect(wrapper.state('searchKey')).toEqual('name');
expect(wrapper.state('searchKey')).toEqual('name__icontains');
wrapper
.instance()
.handleDropdownSelect({ target: { innerText: 'Description' } });
expect(wrapper.state('searchKey')).toEqual('description');
expect(wrapper.state('searchKey')).toEqual('description__icontains');
});
test('attempt to search with empty string', () => {
const searchButton = 'button[aria-label="Search submit button"]';
const searchTextInput = 'input[aria-label="Search text input"]';
const columns = [{ name: 'Name', key: 'name', isDefault: true }];
const columns = [{ name: 'Name', key: 'name__icontains', isDefault: true }];
const onSearch = jest.fn();
const wrapper = mountWithContexts(
<Toolbar
@ -119,7 +119,7 @@ describe('<Search />', () => {
test('search with a valid string', () => {
const searchButton = 'button[aria-label="Search submit button"]';
const searchTextInput = 'input[aria-label="Search text input"]';
const columns = [{ name: 'Name', key: 'name', isDefault: true }];
const columns = [{ name: 'Name', key: 'name__icontains', isDefault: true }];
const onSearch = jest.fn();
const wrapper = mountWithContexts(
<Toolbar
@ -143,12 +143,12 @@ describe('<Search />', () => {
test('filter keys are properly labeled', () => {
const columns = [
{ name: 'Name', key: 'name', isDefault: true },
{ name: 'Type', key: 'type', options: [['foo', 'Foo Bar!']] },
{ name: 'Name', key: 'name__icontains', isDefault: true },
{ name: 'Type', key: 'or__scm_type', options: [['foo', 'Foo Bar!']] },
{ name: 'Description', key: 'description' },
];
const query =
'?organization.or__type=foo&organization.name=bar&item.page_size=10';
'?organization.or__scm_type=foo&organization.name__icontains=bar&item.page_size=10';
const history = createMemoryHistory({
initialEntries: [`/organizations/${query}`],
});
@ -165,13 +165,15 @@ describe('<Search />', () => {
{ context: { router: { history } } }
);
const typeFilterWrapper = wrapper.find(
'ToolbarFilter[categoryName="Type"]'
'ToolbarFilter[categoryName="Type (or__scm_type)"]'
);
expect(typeFilterWrapper.prop('chips')[0].key).toEqual('or__type:foo');
expect(typeFilterWrapper.prop('chips')[0].key).toEqual('or__scm_type:foo');
const nameFilterWrapper = wrapper.find(
'ToolbarFilter[categoryName="Name"]'
'ToolbarFilter[categoryName="Name (name__icontains)"]'
);
expect(nameFilterWrapper.prop('chips')[0].key).toEqual(
'name__icontains:bar'
);
expect(nameFilterWrapper.prop('chips')[0].key).toEqual('name:bar');
});
test('should test handle remove of option-based key', async () => {
@ -265,4 +267,37 @@ describe('<Search />', () => {
});
expect(onRemove).toBeCalledWith('or__type', '');
});
test("ToolbarFilter added for any key that doesn't have search column", () => {
const columns = [
{ name: 'Name', key: 'name__icontains', isDefault: true },
{ name: 'Type', key: 'or__scm_type', options: [['foo', 'Foo Bar!']] },
{ name: 'Description', key: 'description' },
];
const query =
'?organization.or__scm_type=foo&organization.name__icontains=bar&organization.name__exact=baz&item.page_size=10&organization.foo=bar';
const history = createMemoryHistory({
initialEntries: [`/organizations/${query}`],
});
const wrapper = mountWithContexts(
<Toolbar
id={`${QS_CONFIG.namespace}-list-toolbar`}
clearAllFilters={() => {}}
collapseListedFiltersBreakpoint="lg"
>
<ToolbarContent>
<Search qsConfig={QS_CONFIG} columns={columns} />
</ToolbarContent>
</Toolbar>,
{ context: { router: { history } } }
);
const nameExactFilterWrapper = wrapper.find(
'ToolbarFilter[categoryName="name__exact"]'
);
expect(nameExactFilterWrapper.prop('chips')[0].key).toEqual(
'name__exact:baz'
);
const fooFilterWrapper = wrapper.find('ToolbarFilter[categoryName="foo"]');
expect(fooFilterWrapper.prop('chips')[0].key).toEqual('foo:bar');
});
});