diff --git a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx index 1864ebcb5f..cd1abc224c 100644 --- a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx +++ b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx @@ -40,6 +40,7 @@ function DataListToolbar({ qsConfig, pagination, enableNegativeFiltering, + enableRelatedFuzzyFiltering, }) { const showExpandCollapse = onCompact && onExpand; const [isKebabOpen, setIsKebabOpen] = useState(false); @@ -92,6 +93,7 @@ function DataListToolbar({ onShowAdvancedSearch={onShowAdvancedSearch} onRemove={onRemove} enableNegativeFiltering={enableNegativeFiltering} + enableRelatedFuzzyFiltering={enableRelatedFuzzyFiltering} /> {sortColumns && ( @@ -173,6 +175,7 @@ DataListToolbar.propTypes = { onSort: PropTypes.func, additionalControls: PropTypes.arrayOf(PropTypes.node), enableNegativeFiltering: PropTypes.bool, + enableRelatedFuzzyFiltering: PropTypes.bool, }; DataListToolbar.defaultProps = { @@ -192,6 +195,7 @@ DataListToolbar.defaultProps = { onSort: null, additionalControls: [], enableNegativeFiltering: true, + enableRelatedFuzzyFiltering: true, }; export default DataListToolbar; diff --git a/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx b/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx index ea224dc5d1..a353cb2ae1 100644 --- a/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx +++ b/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx @@ -120,6 +120,7 @@ function HostFilterLookup({ organizationId, value, enableNegativeFiltering, + enableRelatedFuzzyFiltering, }) { const history = useHistory(); const location = useLocation(); @@ -370,6 +371,7 @@ function HostFilterLookup({ {...props} fillWidth enableNegativeFiltering={enableNegativeFiltering} + enableRelatedFuzzyFiltering={enableRelatedFuzzyFiltering} /> )} toolbarSearchColumns={searchColumns} @@ -404,6 +406,7 @@ HostFilterLookup.propTypes = { organizationId: number, value: string, enableNegativeFiltering: bool, + enableRelatedFuzzyFiltering: bool, }; HostFilterLookup.defaultProps = { isValid: true, @@ -412,6 +415,7 @@ HostFilterLookup.defaultProps = { organizationId: null, value: '', enableNegativeFiltering: true, + enableRelatedFuzzyFiltering: true, }; export default withRouter(HostFilterLookup); diff --git a/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx index a7d7bd0f61..309ca24339 100644 --- a/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx +++ b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx @@ -7,9 +7,18 @@ export function toSearchParams(string = '') { if (string === '') { return {}; } - return string - .replace(/^\?/, '') - .replace(/&/g, ' and ') + + const readableParamsStr = string.replace(/^\?/, '').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 /) .map(s => s.split('=')) .reduce((searchParams, [k, v]) => { diff --git a/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.test.jsx b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.test.jsx index 157830d29e..653f8750fe 100644 --- a/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.test.jsx +++ b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.test.jsx @@ -33,6 +33,20 @@ describe('toSearchParams', () => { }; 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', () => { @@ -108,6 +122,13 @@ describe('toHostFilter', () => { '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', () => { diff --git a/awx/ui_next/src/components/Search/AdvancedSearch.jsx b/awx/ui_next/src/components/Search/AdvancedSearch.jsx index ef7d8f497c..cf74873254 100644 --- a/awx/ui_next/src/components/Search/AdvancedSearch.jsx +++ b/awx/ui_next/src/components/Search/AdvancedSearch.jsx @@ -1,7 +1,6 @@ import 'styled-components/macro'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; - import { t } from '@lingui/macro'; import { Button, @@ -33,6 +32,7 @@ function AdvancedSearch({ relatedSearchableKeys, maxSelectHeight, enableNegativeFiltering, + enableRelatedFuzzyFiltering, }) { // 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 @@ -48,30 +48,40 @@ function AdvancedSearch({ const [lookupSelection, setLookupSelection] = useState(null); const [keySelection, setKeySelection] = useState(null); const [searchValue, setSearchValue] = useState(''); + const [relatedSearchKeySelected, setRelatedSearchKeySelected] = useState( + false + ); 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 => { // 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('__'); - } + const actualSearchKey = [actualPrefix, keySelection, lookupSelection] + .filter(val => !!val) + .join('__'); onSearch(actualSearchKey, searchValue); setSearchValue(''); } @@ -83,44 +93,211 @@ function AdvancedSearch({ } }; + const renderSetType = () => ( + + ); + + const renderRelatedLookupType = () => ( + + ); + + const renderLookupType = () => ( + + ); + return ( - + {lookupSelection === 'search' ? ( + + {renderSetType()} + + ) : ( + renderSetType() + )} - + {relatedSearchKeySelected + ? renderRelatedLookupType() + : renderLookupType()} ', () => { .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); }); 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'); }); @@ -217,7 +240,7 @@ describe('', () => { wrapper = mountWithContexts( ); @@ -230,6 +253,9 @@ describe('', () => { {}, 'foo' ); + }); + wrapper.update(); + act(() => { wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')( {}, 'exact' @@ -253,7 +279,7 @@ describe('', () => { wrapper = mountWithContexts( ); @@ -265,6 +291,9 @@ describe('', () => { wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( 'foo' ); + }); + wrapper.update(); + act(() => { wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')( {}, 'exact' @@ -305,6 +334,9 @@ describe('', () => { wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( 'foo' ); + }); + wrapper.update(); + act(() => { wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')( {}, 'exact' @@ -363,4 +395,34 @@ describe('', () => { selectOptions.find('SelectOption[id="and-option-select"]').prop('value') ).toBe('and'); }); + + test('Remove search option from related search type', () => { + wrapper = mountWithContexts( + + ); + 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'); + }); }); diff --git a/awx/ui_next/src/components/Search/Search.jsx b/awx/ui_next/src/components/Search/Search.jsx index 9b422dad62..c5ba7cbb13 100644 --- a/awx/ui_next/src/components/Search/Search.jsx +++ b/awx/ui_next/src/components/Search/Search.jsx @@ -43,6 +43,7 @@ function Search({ isDisabled, maxSelectHeight, enableNegativeFiltering, + enableRelatedFuzzyFiltering, }) { const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false); const [searchKey, setSearchKey] = useState( @@ -207,6 +208,7 @@ function Search({ relatedSearchableKeys={relatedSearchableKeys} maxSelectHeight={maxSelectHeight} enableNegativeFiltering={enableNegativeFiltering} + enableRelatedFuzzyFiltering={enableRelatedFuzzyFiltering} /> )) || (options && ( @@ -327,6 +329,7 @@ Search.propTypes = { isDisabled: PropTypes.bool, maxSelectHeight: PropTypes.string, enableNegativeFiltering: PropTypes.bool, + enableRelatedFuzzyFiltering: PropTypes.bool, }; Search.defaultProps = { @@ -335,6 +338,7 @@ Search.defaultProps = { isDisabled: false, maxSelectHeight: '300px', enableNegativeFiltering: true, + enableRelatedFuzzyFiltering: true, }; export default withRouter(Search); diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.jsx index f9ed610f8a..d5e6cda2c7 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.jsx @@ -1,9 +1,7 @@ import React, { useEffect, useCallback } from 'react'; import { useHistory, useLocation, useRouteMatch } from 'react-router-dom'; - import { t } from '@lingui/macro'; import { Card, PageSection } from '@patternfly/react-core'; - import { HostsAPI } from '../../../api'; import AlertModal from '../../../components/AlertModal'; import DataListToolbar from '../../../components/DataListToolbar'; @@ -46,8 +44,15 @@ function HostList() { } }); - const hasNonDefaultSearchParams = - Object.keys(nonDefaultSearchParams).length > 0; + const hasInvalidHostFilterKeys = () => { + const nonDefaultSearchKeys = Object.keys(nonDefaultSearchParams); + return ( + nonDefaultSearchKeys.filter(searchKey => searchKey.startsWith('not__')) + .length > 0 || + nonDefaultSearchKeys.filter(searchKey => searchKey.endsWith('__search')) + .length > 0 + ); + }; const { result: { hosts, count, actions, relatedSearchableKeys, searchableKeys }, @@ -185,7 +190,11 @@ function HostList() { ...(canAdd ? [ handleSmartInventoryClick()} />, ] diff --git a/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.jsx b/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.jsx index fee92e6bc7..4fddf5399f 100644 --- a/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.jsx +++ b/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.jsx @@ -1,52 +1,69 @@ import React from 'react'; -import { func } from 'prop-types'; +import { bool, func } from 'prop-types'; import { Button, DropdownItem, Tooltip } from '@patternfly/react-core'; - import { t } from '@lingui/macro'; import { useKebabifiedMenu } from '../../../contexts/Kebabified'; -function SmartInventoryButton({ onClick, isDisabled }) { +function SmartInventoryButton({ onClick, isDisabled, hasInvalidKeys }) { 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 ( + + {t`Smart Inventory`} + + ); + } + return ( - {t`Smart Inventory`} - + ); - } + }; return ( -
- -
+
{renderContent()}
); } SmartInventoryButton.propTypes = { + hasInvalidKeys: bool, + isDisabled: bool, onClick: func.isRequired, }; +SmartInventoryButton.defaultProps = { + hasInvalidKeys: false, + isDisabled: false, +}; + export default SmartInventoryButton; diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx index 77523ffe32..393439efe2 100644 --- a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx @@ -82,6 +82,7 @@ const SmartInventoryFormFields = ({ inventory }) => { isValid={!hostFilterMeta.touched || !hostFilterMeta.error} isDisabled={!organizationField.value} enableNegativeFiltering={false} + enableRelatedFuzzyFiltering={false} />