mirror of
https://github.com/ansible/awx.git
synced 2026-03-06 19:21:06 -03:30
Merge pull request #10416 from mabashian/10400-search-keys
Addresses bug where advanced search by groups wasn't working on host list SUMMARY link #10400 OK so this got a bit more complicated than I wanted it to but I think on the whole this is a nice improvement. In the initial bug report it was noted that we were ignoring the or/not operators when searching on a related key (in this case it was groups). We were also ignoring the modifier at the end (icontains, exact, startswith, etc). Since we were always defaulting to __search for these types of keys this was actually valid. The API doesn't like us attempting to do something like ?or__groups__search=foo or ?groups__search__icontains because they aren't valid. So, I changed the UX a little bit. A user can still do a fuzzy search on a related key but the prefix is disabled in this case: I changed the third dropdown (specifically for related keys) to contain the following options: search, id, name__icontains which I think would be the three most general cases. id and name__icontains do allow for prefixing (or, not, and). This should make searching on related keys a little bit easier and a little bit more flexible since name and id weren't possible in the UI before. Once place where this is a little different is the host filter in the smart inventory form. Using __search currently throws an error when the user attempts to save. I believe this is an API bug and will file it as such but for now I prevent users from attempting to use __search by removing it as an option for this particular list. When this bug gets fixed in the API we can remove this logic. I also noticed that there was a bug where or__ search terms were being converted to and when using the Smart Inventory button on the host list so I fixed that. Here's the fix in action: ISSUE TYPE Bugfix Pull Request COMPONENT NAME UI Reviewed-by: Kersom <None> Reviewed-by: Tiago Góes <tiago.goes2009@gmail.com>
This commit is contained in:
@@ -40,6 +40,7 @@ function DataListToolbar({
|
|||||||
qsConfig,
|
qsConfig,
|
||||||
pagination,
|
pagination,
|
||||||
enableNegativeFiltering,
|
enableNegativeFiltering,
|
||||||
|
enableRelatedFuzzyFiltering,
|
||||||
}) {
|
}) {
|
||||||
const showExpandCollapse = onCompact && onExpand;
|
const showExpandCollapse = onCompact && onExpand;
|
||||||
const [isKebabOpen, setIsKebabOpen] = useState(false);
|
const [isKebabOpen, setIsKebabOpen] = useState(false);
|
||||||
@@ -92,6 +93,7 @@ function DataListToolbar({
|
|||||||
onShowAdvancedSearch={onShowAdvancedSearch}
|
onShowAdvancedSearch={onShowAdvancedSearch}
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
enableNegativeFiltering={enableNegativeFiltering}
|
enableNegativeFiltering={enableNegativeFiltering}
|
||||||
|
enableRelatedFuzzyFiltering={enableRelatedFuzzyFiltering}
|
||||||
/>
|
/>
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
{sortColumns && (
|
{sortColumns && (
|
||||||
@@ -173,6 +175,7 @@ DataListToolbar.propTypes = {
|
|||||||
onSort: PropTypes.func,
|
onSort: PropTypes.func,
|
||||||
additionalControls: PropTypes.arrayOf(PropTypes.node),
|
additionalControls: PropTypes.arrayOf(PropTypes.node),
|
||||||
enableNegativeFiltering: PropTypes.bool,
|
enableNegativeFiltering: PropTypes.bool,
|
||||||
|
enableRelatedFuzzyFiltering: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
DataListToolbar.defaultProps = {
|
DataListToolbar.defaultProps = {
|
||||||
@@ -192,6 +195,7 @@ DataListToolbar.defaultProps = {
|
|||||||
onSort: null,
|
onSort: null,
|
||||||
additionalControls: [],
|
additionalControls: [],
|
||||||
enableNegativeFiltering: true,
|
enableNegativeFiltering: true,
|
||||||
|
enableRelatedFuzzyFiltering: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DataListToolbar;
|
export default DataListToolbar;
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ function HostFilterLookup({
|
|||||||
organizationId,
|
organizationId,
|
||||||
value,
|
value,
|
||||||
enableNegativeFiltering,
|
enableNegativeFiltering,
|
||||||
|
enableRelatedFuzzyFiltering,
|
||||||
}) {
|
}) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -370,6 +371,7 @@ function HostFilterLookup({
|
|||||||
{...props}
|
{...props}
|
||||||
fillWidth
|
fillWidth
|
||||||
enableNegativeFiltering={enableNegativeFiltering}
|
enableNegativeFiltering={enableNegativeFiltering}
|
||||||
|
enableRelatedFuzzyFiltering={enableRelatedFuzzyFiltering}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
toolbarSearchColumns={searchColumns}
|
toolbarSearchColumns={searchColumns}
|
||||||
@@ -404,6 +406,7 @@ HostFilterLookup.propTypes = {
|
|||||||
organizationId: number,
|
organizationId: number,
|
||||||
value: string,
|
value: string,
|
||||||
enableNegativeFiltering: bool,
|
enableNegativeFiltering: bool,
|
||||||
|
enableRelatedFuzzyFiltering: bool,
|
||||||
};
|
};
|
||||||
HostFilterLookup.defaultProps = {
|
HostFilterLookup.defaultProps = {
|
||||||
isValid: true,
|
isValid: true,
|
||||||
@@ -412,6 +415,7 @@ HostFilterLookup.defaultProps = {
|
|||||||
organizationId: null,
|
organizationId: null,
|
||||||
value: '',
|
value: '',
|
||||||
enableNegativeFiltering: true,
|
enableNegativeFiltering: true,
|
||||||
|
enableRelatedFuzzyFiltering: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withRouter(HostFilterLookup);
|
export default withRouter(HostFilterLookup);
|
||||||
|
|||||||
@@ -7,9 +7,18 @@ export function toSearchParams(string = '') {
|
|||||||
if (string === '') {
|
if (string === '') {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return string
|
|
||||||
.replace(/^\?/, '')
|
const readableParamsStr = string.replace(/^\?/, '').replace(/&/g, ' and ');
|
||||||
.replace(/&/g, ' and ')
|
const orArr = readableParamsStr.split(/ or /);
|
||||||
|
|
||||||
|
if (orArr.length > 1) {
|
||||||
|
orArr.forEach((str, index) => {
|
||||||
|
orArr[index] = `or__${str}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return orArr
|
||||||
|
.join(' and ')
|
||||||
.split(/ and | or /)
|
.split(/ and | or /)
|
||||||
.map(s => s.split('='))
|
.map(s => s.split('='))
|
||||||
.reduce((searchParams, [k, v]) => {
|
.reduce((searchParams, [k, v]) => {
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ describe('toSearchParams', () => {
|
|||||||
};
|
};
|
||||||
expect(toSearchParams(string)).toEqual(paramsObject);
|
expect(toSearchParams(string)).toEqual(paramsObject);
|
||||||
});
|
});
|
||||||
|
test('should take a host filter string separated by or and return search params object with or', () => {
|
||||||
|
string = 'foo=bar or foo=baz';
|
||||||
|
paramsObject = {
|
||||||
|
or__foo: ['bar', 'baz'],
|
||||||
|
};
|
||||||
|
expect(toSearchParams(string)).toEqual(paramsObject);
|
||||||
|
});
|
||||||
|
test('should take a host filter string with or and return search params object with or', () => {
|
||||||
|
string = 'or__foo=1&or__foo=2';
|
||||||
|
paramsObject = {
|
||||||
|
or__foo: ['1', '2'],
|
||||||
|
};
|
||||||
|
expect(toSearchParams(string)).toEqual(paramsObject);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toQueryString', () => {
|
describe('toQueryString', () => {
|
||||||
@@ -108,6 +122,13 @@ describe('toHostFilter', () => {
|
|||||||
'name=foo or name__contains=bar or name__iexact=foo'
|
'name=foo or name__contains=bar or name__iexact=foo'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should return a host filter with or conditional when value is array', () => {
|
||||||
|
const object = {
|
||||||
|
or__groups__id: ['1', '2'],
|
||||||
|
};
|
||||||
|
expect(toHostFilter(object)).toEqual('groups__id=1 or groups__id=2');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('removeNamespacedKeys', () => {
|
describe('removeNamespacedKeys', () => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'styled-components/macro';
|
import 'styled-components/macro';
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -33,6 +32,7 @@ function AdvancedSearch({
|
|||||||
relatedSearchableKeys,
|
relatedSearchableKeys,
|
||||||
maxSelectHeight,
|
maxSelectHeight,
|
||||||
enableNegativeFiltering,
|
enableNegativeFiltering,
|
||||||
|
enableRelatedFuzzyFiltering,
|
||||||
}) {
|
}) {
|
||||||
// TODO: blocked by pf bug, eventually separate these into two groups in the select
|
// 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
|
// for now, I'm spreading set to get rid of duplicate keys...when they are grouped
|
||||||
@@ -48,30 +48,40 @@ function AdvancedSearch({
|
|||||||
const [lookupSelection, setLookupSelection] = useState(null);
|
const [lookupSelection, setLookupSelection] = useState(null);
|
||||||
const [keySelection, setKeySelection] = useState(null);
|
const [keySelection, setKeySelection] = useState(null);
|
||||||
const [searchValue, setSearchValue] = useState('');
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
const [relatedSearchKeySelected, setRelatedSearchKeySelected] = useState(
|
||||||
|
false
|
||||||
|
);
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
keySelection &&
|
||||||
|
relatedSearchableKeys.indexOf(keySelection) > -1 &&
|
||||||
|
searchableKeys.indexOf(keySelection) === -1
|
||||||
|
) {
|
||||||
|
setLookupSelection('name__icontains');
|
||||||
|
setRelatedSearchKeySelected(true);
|
||||||
|
} else {
|
||||||
|
setLookupSelection(null);
|
||||||
|
setRelatedSearchKeySelected(false);
|
||||||
|
}
|
||||||
|
}, [keySelection]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lookupSelection === 'search') {
|
||||||
|
setPrefixSelection(null);
|
||||||
|
}
|
||||||
|
}, [lookupSelection]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const handleAdvancedSearch = e => {
|
const handleAdvancedSearch = e => {
|
||||||
// keeps page from fully reloading
|
// keeps page from fully reloading
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (searchValue) {
|
if (searchValue) {
|
||||||
const actualPrefix = prefixSelection === 'and' ? null : prefixSelection;
|
const actualPrefix = prefixSelection === 'and' ? null : prefixSelection;
|
||||||
let actualSearchKey;
|
const actualSearchKey = [actualPrefix, keySelection, lookupSelection]
|
||||||
// TODO: once we are able to group options for the key typeahead, we will
|
.filter(val => !!val)
|
||||||
// probably want to be able to which group a key was clicked in for duplicates,
|
.join('__');
|
||||||
// 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);
|
onSearch(actualSearchKey, searchValue);
|
||||||
setSearchValue('');
|
setSearchValue('');
|
||||||
}
|
}
|
||||||
@@ -83,44 +93,211 @@ function AdvancedSearch({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderSetType = () => (
|
||||||
|
<Select
|
||||||
|
ouiaId="set-type-typeahead"
|
||||||
|
aria-label={t`Set type select`}
|
||||||
|
className="setTypeSelect"
|
||||||
|
variant={SelectVariant.typeahead}
|
||||||
|
typeAheadAriaLabel={t`Set type typeahead`}
|
||||||
|
onToggle={setIsPrefixDropdownOpen}
|
||||||
|
onSelect={(event, selection) => setPrefixSelection(selection)}
|
||||||
|
onClear={() => setPrefixSelection(null)}
|
||||||
|
selections={prefixSelection}
|
||||||
|
isOpen={isPrefixDropdownOpen}
|
||||||
|
placeholderText={t`Set type`}
|
||||||
|
maxHeight={maxSelectHeight}
|
||||||
|
noResultsFoundText={t`No results found`}
|
||||||
|
isDisabled={lookupSelection === 'search'}
|
||||||
|
>
|
||||||
|
<SelectOption
|
||||||
|
id="and-option-select"
|
||||||
|
key="and"
|
||||||
|
value="and"
|
||||||
|
description={t`Returns results that satisfy this one as well as other filters. This is the default set type if nothing is selected.`}
|
||||||
|
/>
|
||||||
|
<SelectOption
|
||||||
|
id="or-option-select"
|
||||||
|
key="or"
|
||||||
|
value="or"
|
||||||
|
description={t`Returns results that satisfy this one or any other filters.`}
|
||||||
|
/>
|
||||||
|
{enableNegativeFiltering && (
|
||||||
|
<SelectOption
|
||||||
|
id="not-option-select"
|
||||||
|
key="not"
|
||||||
|
value="not"
|
||||||
|
description={t`Returns results that have values other than this one as well as other filters.`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRelatedLookupType = () => (
|
||||||
|
<Select
|
||||||
|
ouiaId="set-lookup-typeahead"
|
||||||
|
aria-label={t`Related search type`}
|
||||||
|
className="lookupSelect"
|
||||||
|
variant={SelectVariant.typeahead}
|
||||||
|
typeAheadAriaLabel={t`Related search type typeahead`}
|
||||||
|
onToggle={setIsLookupDropdownOpen}
|
||||||
|
onSelect={(event, selection) => setLookupSelection(selection)}
|
||||||
|
selections={lookupSelection}
|
||||||
|
isOpen={isLookupDropdownOpen}
|
||||||
|
placeholderText={t`Related search type`}
|
||||||
|
maxHeight={maxSelectHeight}
|
||||||
|
noResultsFoundText={t`No results found`}
|
||||||
|
>
|
||||||
|
<SelectOption
|
||||||
|
id="name-option-select"
|
||||||
|
key="name__icontains"
|
||||||
|
value="name__icontains"
|
||||||
|
description={t`Fuzzy search on name field.`}
|
||||||
|
/>
|
||||||
|
<SelectOption
|
||||||
|
id="id-option-select"
|
||||||
|
key="id"
|
||||||
|
value="id"
|
||||||
|
description={t`Exact search on id field.`}
|
||||||
|
/>
|
||||||
|
{enableRelatedFuzzyFiltering && (
|
||||||
|
<SelectOption
|
||||||
|
id="search-option-select"
|
||||||
|
key="search"
|
||||||
|
value="search"
|
||||||
|
description={t`Fuzzy search on id, name or description fields.`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderLookupType = () => (
|
||||||
|
<Select
|
||||||
|
ouiaId="set-lookup-typeahead"
|
||||||
|
aria-label={t`Lookup select`}
|
||||||
|
className="lookupSelect"
|
||||||
|
variant={SelectVariant.typeahead}
|
||||||
|
typeAheadAriaLabel={t`Lookup typeahead`}
|
||||||
|
onToggle={setIsLookupDropdownOpen}
|
||||||
|
onSelect={(event, selection) => setLookupSelection(selection)}
|
||||||
|
onClear={() => setLookupSelection(null)}
|
||||||
|
selections={lookupSelection}
|
||||||
|
isOpen={isLookupDropdownOpen}
|
||||||
|
placeholderText={t`Lookup type`}
|
||||||
|
maxHeight={maxSelectHeight}
|
||||||
|
noResultsFoundText={t`No results found`}
|
||||||
|
>
|
||||||
|
<SelectOption
|
||||||
|
id="exact-option-select"
|
||||||
|
key="exact"
|
||||||
|
value="exact"
|
||||||
|
description={t`Exact match (default lookup if not specified).`}
|
||||||
|
/>
|
||||||
|
<SelectOption
|
||||||
|
id="iexact-option-select"
|
||||||
|
key="iexact"
|
||||||
|
value="iexact"
|
||||||
|
description={t`Case-insensitive version of exact.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectOption
|
||||||
|
id="contains-option-select"
|
||||||
|
key="contains"
|
||||||
|
value="contains"
|
||||||
|
description={t`Field contains value.`}
|
||||||
|
/>
|
||||||
|
<SelectOption
|
||||||
|
id="icontains-option-select"
|
||||||
|
key="icontains"
|
||||||
|
value="icontains"
|
||||||
|
description={t`Case-insensitive version of contains`}
|
||||||
|
/>
|
||||||
|
<SelectOption
|
||||||
|
id="startswith-option-select"
|
||||||
|
key="startswith"
|
||||||
|
value="startswith"
|
||||||
|
description={t`Field starts with value.`}
|
||||||
|
/>
|
||||||
|
<SelectOption
|
||||||
|
id="istartswith-option-select"
|
||||||
|
key="istartswith"
|
||||||
|
value="istartswith"
|
||||||
|
description={t`Case-insensitive version of startswith.`}
|
||||||
|
/>
|
||||||
|
<SelectOption
|
||||||
|
id="endswith-option-select"
|
||||||
|
key="endswith"
|
||||||
|
value="endswith"
|
||||||
|
description={t`Field ends with value.`}
|
||||||
|
/>
|
||||||
|
<SelectOption
|
||||||
|
id="iendswith-option-select"
|
||||||
|
key="iendswith"
|
||||||
|
value="iendswith"
|
||||||
|
description={t`Case-insensitive version of endswith.`}
|
||||||
|
/>
|
||||||
|
<SelectOption
|
||||||
|
id="regex-option-select"
|
||||||
|
key="regex"
|
||||||
|
value="regex"
|
||||||
|
description={t`Field matches the given regular expression.`}
|
||||||
|
/>
|
||||||
|
<SelectOption
|
||||||
|
id="iregex-option-select"
|
||||||
|
key="iregex"
|
||||||
|
value="iregex"
|
||||||
|
description={t`Case-insensitive version of regex.`}
|
||||||
|
/>
|
||||||
|
<SelectOption
|
||||||
|
id="gt-option-select"
|
||||||
|
key="gt"
|
||||||
|
value="gt"
|
||||||
|
description={t`Greater than comparison.`}
|
||||||
|
/>
|
||||||
|
<SelectOption
|
||||||
|
id="gte-option-select"
|
||||||
|
key="gte"
|
||||||
|
value="gte"
|
||||||
|
description={t`Greater than or equal to comparison.`}
|
||||||
|
/>
|
||||||
|
<SelectOption
|
||||||
|
id="lt-option-select"
|
||||||
|
key="lt"
|
||||||
|
value="lt"
|
||||||
|
description={t`Less than comparison.`}
|
||||||
|
/>
|
||||||
|
<SelectOption
|
||||||
|
id="lte-option-select"
|
||||||
|
key="lte"
|
||||||
|
value="lte"
|
||||||
|
description={t`Less than or equal to comparison.`}
|
||||||
|
/>
|
||||||
|
<SelectOption
|
||||||
|
id="isnull-option-select"
|
||||||
|
key="isnull"
|
||||||
|
value="isnull"
|
||||||
|
description={t`Check whether the given field or related object is null; expects a boolean value.`}
|
||||||
|
/>
|
||||||
|
<SelectOption
|
||||||
|
id="in-option-select"
|
||||||
|
key="in"
|
||||||
|
value="in"
|
||||||
|
description={t`Check whether the given field's value is present in the list provided; expects a comma-separated list of items.`}
|
||||||
|
/>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdvancedGroup>
|
<AdvancedGroup>
|
||||||
<Select
|
{lookupSelection === 'search' ? (
|
||||||
ouiaId="set-type-typeahead"
|
<Tooltip
|
||||||
aria-label={t`Set type select`}
|
content={t`Set type disabled for related search field fuzzy searches`}
|
||||||
className="setTypeSelect"
|
>
|
||||||
variant={SelectVariant.typeahead}
|
{renderSetType()}
|
||||||
typeAheadAriaLabel={t`Set type typeahead`}
|
</Tooltip>
|
||||||
onToggle={setIsPrefixDropdownOpen}
|
) : (
|
||||||
onSelect={(event, selection) => setPrefixSelection(selection)}
|
renderSetType()
|
||||||
onClear={() => setPrefixSelection(null)}
|
)}
|
||||||
selections={prefixSelection}
|
|
||||||
isOpen={isPrefixDropdownOpen}
|
|
||||||
placeholderText={t`Set type`}
|
|
||||||
maxHeight={maxSelectHeight}
|
|
||||||
noResultsFoundText={t`No results found`}
|
|
||||||
>
|
|
||||||
<SelectOption
|
|
||||||
id="and-option-select"
|
|
||||||
key="and"
|
|
||||||
value="and"
|
|
||||||
description={t`Returns results that satisfy this one as well as other filters. This is the default set type if nothing is selected.`}
|
|
||||||
/>
|
|
||||||
<SelectOption
|
|
||||||
id="or-option-select"
|
|
||||||
key="or"
|
|
||||||
value="or"
|
|
||||||
description={t`Returns results that satisfy this one or any other filters.`}
|
|
||||||
/>
|
|
||||||
{enableNegativeFiltering && (
|
|
||||||
<SelectOption
|
|
||||||
id="not-option-select"
|
|
||||||
key="not"
|
|
||||||
value="not"
|
|
||||||
description={t`Returns results that have values other than this one as well as other filters.`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Select>
|
|
||||||
<Select
|
<Select
|
||||||
ouiaId="set-key-typeahead"
|
ouiaId="set-key-typeahead"
|
||||||
aria-label={t`Key select`}
|
aria-label={t`Key select`}
|
||||||
@@ -148,118 +325,9 @@ function AdvancedSearch({
|
|||||||
</SelectOption>
|
</SelectOption>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
{relatedSearchKeySelected
|
||||||
ouiaId="set-lookup-typeahead"
|
? renderRelatedLookupType()
|
||||||
aria-label={t`Lookup select`}
|
: renderLookupType()}
|
||||||
className="lookupSelect"
|
|
||||||
variant={SelectVariant.typeahead}
|
|
||||||
typeAheadAriaLabel={t`Lookup typeahead`}
|
|
||||||
onToggle={setIsLookupDropdownOpen}
|
|
||||||
onSelect={(event, selection) => setLookupSelection(selection)}
|
|
||||||
onClear={() => setLookupSelection(null)}
|
|
||||||
selections={lookupSelection}
|
|
||||||
isOpen={isLookupDropdownOpen}
|
|
||||||
placeholderText={t`Lookup type`}
|
|
||||||
maxHeight={maxSelectHeight}
|
|
||||||
noResultsFoundText={t`No results found`}
|
|
||||||
>
|
|
||||||
<SelectOption
|
|
||||||
id="exact-option-select"
|
|
||||||
key="exact"
|
|
||||||
value="exact"
|
|
||||||
description={t`Exact match (default lookup if not specified).`}
|
|
||||||
/>
|
|
||||||
<SelectOption
|
|
||||||
id="iexact-option-select"
|
|
||||||
key="iexact"
|
|
||||||
value="iexact"
|
|
||||||
description={t`Case-insensitive version of exact.`}
|
|
||||||
/>
|
|
||||||
<SelectOption
|
|
||||||
id="contains-option-select"
|
|
||||||
key="contains"
|
|
||||||
value="contains"
|
|
||||||
description={t`Field contains value.`}
|
|
||||||
/>
|
|
||||||
<SelectOption
|
|
||||||
id="icontains-option-select"
|
|
||||||
key="icontains"
|
|
||||||
value="icontains"
|
|
||||||
description={t`Case-insensitive version of contains`}
|
|
||||||
/>
|
|
||||||
<SelectOption
|
|
||||||
id="startswith-option-select"
|
|
||||||
key="startswith"
|
|
||||||
value="startswith"
|
|
||||||
description={t`Field starts with value.`}
|
|
||||||
/>
|
|
||||||
<SelectOption
|
|
||||||
id="istartswith-option-select"
|
|
||||||
key="istartswith"
|
|
||||||
value="istartswith"
|
|
||||||
description={t`Case-insensitive version of startswith.`}
|
|
||||||
/>
|
|
||||||
<SelectOption
|
|
||||||
id="endswith-option-select"
|
|
||||||
key="endswith"
|
|
||||||
value="endswith"
|
|
||||||
description={t`Field ends with value.`}
|
|
||||||
/>
|
|
||||||
<SelectOption
|
|
||||||
id="iendswith-option-select"
|
|
||||||
key="iendswith"
|
|
||||||
value="iendswith"
|
|
||||||
description={t`Case-insensitive version of endswith.`}
|
|
||||||
/>
|
|
||||||
<SelectOption
|
|
||||||
id="regex-option-select"
|
|
||||||
key="regex"
|
|
||||||
value="regex"
|
|
||||||
description={t`Field matches the given regular expression.`}
|
|
||||||
/>
|
|
||||||
<SelectOption
|
|
||||||
id="iregex-option-select"
|
|
||||||
key="iregex"
|
|
||||||
value="iregex"
|
|
||||||
description={t`Case-insensitive version of regex.`}
|
|
||||||
/>
|
|
||||||
<SelectOption
|
|
||||||
id="gt-option-select"
|
|
||||||
key="gt"
|
|
||||||
value="gt"
|
|
||||||
description={t`Greater than comparison.`}
|
|
||||||
/>
|
|
||||||
<SelectOption
|
|
||||||
id="gte-option-select"
|
|
||||||
key="gte"
|
|
||||||
value="gte"
|
|
||||||
description={t`Greater than or equal to comparison.`}
|
|
||||||
/>
|
|
||||||
<SelectOption
|
|
||||||
id="lt-option-select"
|
|
||||||
key="lt"
|
|
||||||
value="lt"
|
|
||||||
description={t`Less than comparison.`}
|
|
||||||
/>
|
|
||||||
<SelectOption
|
|
||||||
id="lte-option-select"
|
|
||||||
key="lte"
|
|
||||||
value="lte"
|
|
||||||
description={t`Less than or equal to comparison.`}
|
|
||||||
/>
|
|
||||||
<SelectOption
|
|
||||||
id="isnull-option-select"
|
|
||||||
key="isnull"
|
|
||||||
value="isnull"
|
|
||||||
description={t`Check whether the given field or related object is null; expects a boolean value.`}
|
|
||||||
/>
|
|
||||||
<SelectOption
|
|
||||||
id="in-option-select"
|
|
||||||
key="in"
|
|
||||||
value="in"
|
|
||||||
description={t`Check whether the given field's value is present in the list provided; expects a comma-separated list of items.`}
|
|
||||||
/>
|
|
||||||
</Select>
|
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<TextInput
|
<TextInput
|
||||||
data-cy="advanced-search-text-input"
|
data-cy="advanced-search-text-input"
|
||||||
@@ -303,6 +371,7 @@ AdvancedSearch.propTypes = {
|
|||||||
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
|
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
|
||||||
maxSelectHeight: PropTypes.string,
|
maxSelectHeight: PropTypes.string,
|
||||||
enableNegativeFiltering: PropTypes.bool,
|
enableNegativeFiltering: PropTypes.bool,
|
||||||
|
enableRelatedFuzzyFiltering: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
AdvancedSearch.defaultProps = {
|
AdvancedSearch.defaultProps = {
|
||||||
@@ -310,6 +379,7 @@ AdvancedSearch.defaultProps = {
|
|||||||
relatedSearchableKeys: [],
|
relatedSearchableKeys: [],
|
||||||
maxSelectHeight: '300px',
|
maxSelectHeight: '300px',
|
||||||
enableNegativeFiltering: true,
|
enableNegativeFiltering: true,
|
||||||
|
enableRelatedFuzzyFiltering: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdvancedSearch;
|
export default AdvancedSearch;
|
||||||
|
|||||||
@@ -209,6 +209,29 @@ describe('<AdvancedSearch />', () => {
|
|||||||
.prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn });
|
.prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn });
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
expect(advancedSearchMock).toBeCalledWith('baz__name__icontains', 'bar');
|
||||||
|
jest.clearAllMocks();
|
||||||
|
act(() => {
|
||||||
|
wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')(
|
||||||
|
'baz'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
act(() => {
|
||||||
|
wrapper
|
||||||
|
.find('Select[aria-label="Related search type"]')
|
||||||
|
.invoke('onSelect')({}, 'search');
|
||||||
|
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');
|
expect(advancedSearchMock).toBeCalledWith('baz__search', 'bar');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,7 +240,7 @@ describe('<AdvancedSearch />', () => {
|
|||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<AdvancedSearch
|
<AdvancedSearch
|
||||||
onSearch={advancedSearchMock}
|
onSearch={advancedSearchMock}
|
||||||
searchableKeys={[]}
|
searchableKeys={['foo']}
|
||||||
relatedSearchableKeys={[]}
|
relatedSearchableKeys={[]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -230,6 +253,9 @@ describe('<AdvancedSearch />', () => {
|
|||||||
{},
|
{},
|
||||||
'foo'
|
'foo'
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
act(() => {
|
||||||
wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')(
|
wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')(
|
||||||
{},
|
{},
|
||||||
'exact'
|
'exact'
|
||||||
@@ -253,7 +279,7 @@ describe('<AdvancedSearch />', () => {
|
|||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<AdvancedSearch
|
<AdvancedSearch
|
||||||
onSearch={advancedSearchMock}
|
onSearch={advancedSearchMock}
|
||||||
searchableKeys={[]}
|
searchableKeys={['foo']}
|
||||||
relatedSearchableKeys={[]}
|
relatedSearchableKeys={[]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -265,6 +291,9 @@ describe('<AdvancedSearch />', () => {
|
|||||||
wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')(
|
wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')(
|
||||||
'foo'
|
'foo'
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
act(() => {
|
||||||
wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')(
|
wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')(
|
||||||
{},
|
{},
|
||||||
'exact'
|
'exact'
|
||||||
@@ -305,6 +334,9 @@ describe('<AdvancedSearch />', () => {
|
|||||||
wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')(
|
wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')(
|
||||||
'foo'
|
'foo'
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
act(() => {
|
||||||
wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')(
|
wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')(
|
||||||
{},
|
{},
|
||||||
'exact'
|
'exact'
|
||||||
@@ -363,4 +395,34 @@ describe('<AdvancedSearch />', () => {
|
|||||||
selectOptions.find('SelectOption[id="and-option-select"]').prop('value')
|
selectOptions.find('SelectOption[id="and-option-select"]').prop('value')
|
||||||
).toBe('and');
|
).toBe('and');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Remove search option from related search type', () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<AdvancedSearch
|
||||||
|
onSearch={jest.fn}
|
||||||
|
searchableKeys={['foo', 'bar']}
|
||||||
|
relatedSearchableKeys={['bar', 'baz']}
|
||||||
|
enableRelatedFuzzyFiltering={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')(
|
||||||
|
'baz'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
wrapper
|
||||||
|
.find('Select[aria-label="Related search type"] SelectToggle')
|
||||||
|
.simulate('click');
|
||||||
|
const selectOptions = wrapper.find(
|
||||||
|
'Select[aria-label="Related search type"] SelectOption'
|
||||||
|
);
|
||||||
|
expect(selectOptions).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
selectOptions.find('SelectOption[id="name-option-select"]').prop('value')
|
||||||
|
).toBe('name__icontains');
|
||||||
|
expect(
|
||||||
|
selectOptions.find('SelectOption[id="id-option-select"]').prop('value')
|
||||||
|
).toBe('id');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function Search({
|
|||||||
isDisabled,
|
isDisabled,
|
||||||
maxSelectHeight,
|
maxSelectHeight,
|
||||||
enableNegativeFiltering,
|
enableNegativeFiltering,
|
||||||
|
enableRelatedFuzzyFiltering,
|
||||||
}) {
|
}) {
|
||||||
const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false);
|
const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false);
|
||||||
const [searchKey, setSearchKey] = useState(
|
const [searchKey, setSearchKey] = useState(
|
||||||
@@ -207,6 +208,7 @@ function Search({
|
|||||||
relatedSearchableKeys={relatedSearchableKeys}
|
relatedSearchableKeys={relatedSearchableKeys}
|
||||||
maxSelectHeight={maxSelectHeight}
|
maxSelectHeight={maxSelectHeight}
|
||||||
enableNegativeFiltering={enableNegativeFiltering}
|
enableNegativeFiltering={enableNegativeFiltering}
|
||||||
|
enableRelatedFuzzyFiltering={enableRelatedFuzzyFiltering}
|
||||||
/>
|
/>
|
||||||
)) ||
|
)) ||
|
||||||
(options && (
|
(options && (
|
||||||
@@ -327,6 +329,7 @@ Search.propTypes = {
|
|||||||
isDisabled: PropTypes.bool,
|
isDisabled: PropTypes.bool,
|
||||||
maxSelectHeight: PropTypes.string,
|
maxSelectHeight: PropTypes.string,
|
||||||
enableNegativeFiltering: PropTypes.bool,
|
enableNegativeFiltering: PropTypes.bool,
|
||||||
|
enableRelatedFuzzyFiltering: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
Search.defaultProps = {
|
Search.defaultProps = {
|
||||||
@@ -335,6 +338,7 @@ Search.defaultProps = {
|
|||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
maxSelectHeight: '300px',
|
maxSelectHeight: '300px',
|
||||||
enableNegativeFiltering: true,
|
enableNegativeFiltering: true,
|
||||||
|
enableRelatedFuzzyFiltering: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withRouter(Search);
|
export default withRouter(Search);
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import React, { useEffect, useCallback } from 'react';
|
import React, { useEffect, useCallback } from 'react';
|
||||||
import { useHistory, useLocation, useRouteMatch } from 'react-router-dom';
|
import { useHistory, useLocation, useRouteMatch } from 'react-router-dom';
|
||||||
|
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Card, PageSection } from '@patternfly/react-core';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
|
|
||||||
import { HostsAPI } from '../../../api';
|
import { HostsAPI } from '../../../api';
|
||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
import DataListToolbar from '../../../components/DataListToolbar';
|
import DataListToolbar from '../../../components/DataListToolbar';
|
||||||
@@ -46,8 +44,15 @@ function HostList() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasNonDefaultSearchParams =
|
const hasInvalidHostFilterKeys = () => {
|
||||||
Object.keys(nonDefaultSearchParams).length > 0;
|
const nonDefaultSearchKeys = Object.keys(nonDefaultSearchParams);
|
||||||
|
return (
|
||||||
|
nonDefaultSearchKeys.filter(searchKey => searchKey.startsWith('not__'))
|
||||||
|
.length > 0 ||
|
||||||
|
nonDefaultSearchKeys.filter(searchKey => searchKey.endsWith('__search'))
|
||||||
|
.length > 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
result: { hosts, count, actions, relatedSearchableKeys, searchableKeys },
|
result: { hosts, count, actions, relatedSearchableKeys, searchableKeys },
|
||||||
@@ -185,7 +190,11 @@ function HostList() {
|
|||||||
...(canAdd
|
...(canAdd
|
||||||
? [
|
? [
|
||||||
<SmartInventoryButton
|
<SmartInventoryButton
|
||||||
isDisabled={!hasNonDefaultSearchParams}
|
hasInvalidKeys={hasInvalidHostFilterKeys()}
|
||||||
|
isDisabled={
|
||||||
|
Object.keys(nonDefaultSearchParams).length === 0 ||
|
||||||
|
hasInvalidHostFilterKeys()
|
||||||
|
}
|
||||||
onClick={() => handleSmartInventoryClick()}
|
onClick={() => handleSmartInventoryClick()}
|
||||||
/>,
|
/>,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,52 +1,69 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { func } from 'prop-types';
|
import { bool, func } from 'prop-types';
|
||||||
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
||||||
|
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { useKebabifiedMenu } from '../../../contexts/Kebabified';
|
import { useKebabifiedMenu } from '../../../contexts/Kebabified';
|
||||||
|
|
||||||
function SmartInventoryButton({ onClick, isDisabled }) {
|
function SmartInventoryButton({ onClick, isDisabled, hasInvalidKeys }) {
|
||||||
const { isKebabified } = useKebabifiedMenu();
|
const { isKebabified } = useKebabifiedMenu();
|
||||||
|
|
||||||
if (isKebabified) {
|
const renderTooltipContent = () => {
|
||||||
|
if (hasInvalidKeys) {
|
||||||
|
return t`Some search modifiers like not__ and __search are not supported in Smart Inventory host filters. Remove these to create a new Smart Inventory with this filter.`;
|
||||||
|
}
|
||||||
|
if (isDisabled) {
|
||||||
|
return t`Enter at least one search filter to create a new Smart Inventory`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t`Create a new Smart Inventory with the applied filter`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (isKebabified) {
|
||||||
|
return (
|
||||||
|
<DropdownItem
|
||||||
|
key="add"
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
component="button"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{t`Smart Inventory`}
|
||||||
|
</DropdownItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownItem
|
<Button
|
||||||
key="add"
|
ouiaId="smart-inventory-button"
|
||||||
isDisabled={isDisabled}
|
|
||||||
component="button"
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
aria-label={t`Smart Inventory`}
|
||||||
|
variant="secondary"
|
||||||
|
isDisabled={isDisabled}
|
||||||
>
|
>
|
||||||
{t`Smart Inventory`}
|
{t`Smart Inventory`}
|
||||||
</DropdownItem>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key="smartInventory"
|
key="smartInventory"
|
||||||
content={
|
content={renderTooltipContent()}
|
||||||
!isDisabled
|
|
||||||
? t`Create a new Smart Inventory with the applied filter`
|
|
||||||
: t`Enter at least one search filter to create a new Smart Inventory`
|
|
||||||
}
|
|
||||||
position="top"
|
position="top"
|
||||||
>
|
>
|
||||||
<div>
|
<div>{renderContent()}</div>
|
||||||
<Button
|
|
||||||
ouiaId="smart-inventory-button"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-label={t`Smart Inventory`}
|
|
||||||
variant="secondary"
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
>
|
|
||||||
{t`Smart Inventory`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
SmartInventoryButton.propTypes = {
|
SmartInventoryButton.propTypes = {
|
||||||
|
hasInvalidKeys: bool,
|
||||||
|
isDisabled: bool,
|
||||||
onClick: func.isRequired,
|
onClick: func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
SmartInventoryButton.defaultProps = {
|
||||||
|
hasInvalidKeys: false,
|
||||||
|
isDisabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
export default SmartInventoryButton;
|
export default SmartInventoryButton;
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ const SmartInventoryFormFields = ({ inventory }) => {
|
|||||||
isValid={!hostFilterMeta.touched || !hostFilterMeta.error}
|
isValid={!hostFilterMeta.touched || !hostFilterMeta.error}
|
||||||
isDisabled={!organizationField.value}
|
isDisabled={!organizationField.value}
|
||||||
enableNegativeFiltering={false}
|
enableNegativeFiltering={false}
|
||||||
|
enableRelatedFuzzyFiltering={false}
|
||||||
/>
|
/>
|
||||||
<InstanceGroupsLookup
|
<InstanceGroupsLookup
|
||||||
value={instanceGroupsField.value}
|
value={instanceGroupsField.value}
|
||||||
|
|||||||
Reference in New Issue
Block a user