From da733538c493c22ddb4d09ac2fcee8cad29517b6 Mon Sep 17 00:00:00 2001 From: nixocio Date: Tue, 26 Oct 2021 17:00:10 -0400 Subject: [PATCH] Modify usage of ansible_facts on advanced search Modify usage of ansible_facts on advanced search, once `ansible_facts` key is selected render a text input allowing the user to type special query expected for ansible_facts. This change will add more flexibility to the usage of ansible_facts when creating a smart inventory. See: https://github.com/ansible/awx/issues/11017 --- .../DataListToolbar/DataListToolbar.js | 4 + .../src/components/ListHeader/ListHeader.js | 5 +- .../components/ListHeader/ListHeader.test.js | 5 +- .../src/components/Lookup/HostFilterLookup.js | 53 ++++++- .../Lookup/shared/HostFilterUtils.js | 32 +++- .../Lookup/shared/HostFilterUtils.test.js | 32 ++++ .../src/components/Search/AdvancedSearch.js | 147 +++++++++++++----- awx/ui/src/components/Search/Search.js | 23 ++- awx/ui/src/screens/Host/HostList/HostList.js | 12 +- .../screens/Host/HostList/HostList.test.js | 19 +++ .../Host/HostList/SmartInventoryButton.js | 12 +- .../SmartInventoryAdd/SmartInventoryAdd.js | 5 +- .../SmartInventoryAdd.test.js | 20 +++ .../SmartInventoryEdit/SmartInventoryEdit.js | 4 +- awx/ui/src/screens/Inventory/index.js | 1 + .../Inventory/shared/SmartInventoryForm.js | 10 +- awx/ui/src/screens/Inventory/shared/utils.js | 10 ++ .../screens/Inventory/shared/utils.test.js | 21 +++ 18 files changed, 362 insertions(+), 53 deletions(-) create mode 100644 awx/ui/src/screens/Inventory/shared/utils.js create mode 100644 awx/ui/src/screens/Inventory/shared/utils.test.js diff --git a/awx/ui/src/components/DataListToolbar/DataListToolbar.js b/awx/ui/src/components/DataListToolbar/DataListToolbar.js index b81c8e1645..f2d13d01ba 100644 --- a/awx/ui/src/components/DataListToolbar/DataListToolbar.js +++ b/awx/ui/src/components/DataListToolbar/DataListToolbar.js @@ -55,6 +55,8 @@ function DataListToolbar({ pagination, enableNegativeFiltering, enableRelatedFuzzyFiltering, + handleIsAnsibleFactsSelected, + isFilterCleared, }) { const { search } = useLocation(); const showExpandCollapse = onCompact && onExpand; @@ -143,6 +145,8 @@ function DataListToolbar({ onRemove={onRemove} enableNegativeFiltering={enableNegativeFiltering} enableRelatedFuzzyFiltering={enableRelatedFuzzyFiltering} + handleIsAnsibleFactsSelected={handleIsAnsibleFactsSelected} + isFilterCleared={isFilterCleared} /> {sortColumns && ( diff --git a/awx/ui/src/components/ListHeader/ListHeader.js b/awx/ui/src/components/ListHeader/ListHeader.js index bb881850a4..8fc3e300b6 100644 --- a/awx/ui/src/components/ListHeader/ListHeader.js +++ b/awx/ui/src/components/ListHeader/ListHeader.js @@ -1,5 +1,5 @@ /* eslint-disable react/jsx-no-useless-fragment */ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { useHistory, useLocation } from 'react-router-dom'; import styled from 'styled-components'; @@ -27,6 +27,7 @@ const EmptyStateControlsWrapper = styled.div` `; function ListHeader(props) { const { search, pathname } = useLocation(); + const [isFilterCleared, setIsFilterCleared] = useState(false); const history = useHistory(); const { emptyStateControls, @@ -73,6 +74,7 @@ function ListHeader(props) { delete oldParams.page_size; delete oldParams.order_by; const qs = updateQueryString(qsConfig, search, oldParams); + setIsFilterCleared(true); pushHistoryState(qs); }; @@ -120,6 +122,7 @@ function ListHeader(props) { clearAllFilters: handleRemoveAll, qsConfig, pagination, + isFilterCleared, })} )} diff --git a/awx/ui/src/components/ListHeader/ListHeader.test.js b/awx/ui/src/components/ListHeader/ListHeader.test.js index 60e304110a..c4fa88000a 100644 --- a/awx/ui/src/components/ListHeader/ListHeader.test.js +++ b/awx/ui/src/components/ListHeader/ListHeader.test.js @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import ListHeader from './ListHeader'; @@ -74,7 +75,9 @@ describe('ListHeader', () => { expect(history.location.search).toEqual(query); const toolbar = wrapper.find('DataListToolbar'); - toolbar.prop('clearAllFilters')(); + act(() => { + toolbar.prop('clearAllFilters')(); + }); expect(history.location.search).toEqual('?item.page_size=5'); }); diff --git a/awx/ui/src/components/Lookup/HostFilterLookup.js b/awx/ui/src/components/Lookup/HostFilterLookup.js index c4cf9aa0df..e580446813 100644 --- a/awx/ui/src/components/Lookup/HostFilterLookup.js +++ b/awx/ui/src/components/Lookup/HostFilterLookup.js @@ -6,6 +6,7 @@ import styled from 'styled-components'; import { t } from '@lingui/macro'; import { SearchIcon } from '@patternfly/react-icons'; import { + Alert as PFAlert, Button, ButtonVariant, Chip, @@ -16,6 +17,8 @@ import { } from '@patternfly/react-core'; import { HostsAPI } from 'api'; import { getQSConfig, mergeParams, parseQueryString } from 'util/qs'; +import getDocsBaseUrl from 'util/getDocsBaseUrl'; +import { useConfig } from 'contexts/Config'; import useRequest, { useDismissableError } from 'hooks/useRequest'; import ChipGroup from '../ChipGroup'; import Popover from '../Popover'; @@ -33,8 +36,15 @@ import { toHostFilter, toQueryString, toSearchParams, + modifyHostFilter, } from './shared/HostFilterUtils'; +const Alert = styled(PFAlert)` + && { + margin-bottom: 8px; + } +`; + const ChipHolder = styled.div` && { --pf-c-form-control--Height: auto; @@ -131,7 +141,10 @@ function HostFilterLookup({ const [chips, setChips] = useState({}); const [queryString, setQueryString] = useState(''); const { isModalOpen, toggleModal, closeModal } = useModal(); + const [isAnsibleFactsSelected, setIsAnsibleFactsSelected] = useState(false); + const searchColumns = buildSearchColumns(); + const config = useConfig(); const parseRelatedSearchFields = (searchFields) => { if (searchFields.indexOf('__search') !== -1) { @@ -185,8 +198,10 @@ function HostFilterLookup({ useEffect(() => { const filters = toSearchParams(value); - setQueryString(toQueryString(QS_CONFIG, filters)); - setChips(buildChips(filters)); + let modifiedFilters = modifyHostFilter(value, filters); + setQueryString(toQueryString(QS_CONFIG, modifiedFilters)); + modifiedFilters = removeHostFilter(modifiedFilters); + setChips(buildChips(modifiedFilters)); }, [value]); function qsToHostFilter(qs) { @@ -209,6 +224,17 @@ function HostFilterLookup({ }); }; + const removeHostFilter = (filter) => { + if ('host_filter' in filter) { + filter.ansible_facts = filter.host_filter.substring( + 'ansible_facts__'.length + ); + delete filter.host_filter; + } + + return filter; + }; + function buildChips(filter = {}) { const inputGroupChips = Object.keys(filter).reduce((obj, param) => { const parsedKey = param.replace('or__', ''); @@ -320,7 +346,7 @@ function HostFilterLookup({ labelIcon={ + {isAnsibleFactsSelected && ( + + {t`Searching by ansible_facts requires special syntax. Refer to the`}{' '} + + {t`documentation`} + {' '} + {t`for more info.`} + + } + /> + )} )} toolbarSearchColumns={searchColumns} diff --git a/awx/ui/src/components/Lookup/shared/HostFilterUtils.js b/awx/ui/src/components/Lookup/shared/HostFilterUtils.js index 0957e0b899..986df2c901 100644 --- a/awx/ui/src/components/Lookup/shared/HostFilterUtils.js +++ b/awx/ui/src/components/Lookup/shared/HostFilterUtils.js @@ -20,7 +20,8 @@ export function toSearchParams(string = '') { const unescapeString = (v) => // This is necessary when editing a string that was initially // escaped to allow white space - v.replace(/"/g, ''); + v ? v.replace(/"/g, '') : ''; + return orArr .join(' and ') .split(/ and | or /) @@ -159,3 +160,32 @@ export function removeDefaultParams(config, obj = {}) { }); return clonedObj; } + +/** + * Helper function to update host_filter value + * @param {string} value A string with host_filter value from querystring + * @param {object} obj An object returned by toSearchParams - in which the + * host_filter value was partially removed. + * @return {object} An object with the value of host_filter modified + */ +export function modifyHostFilter(value, obj) { + if (!value.includes('host_filter=')) return obj; + const clonedObj = { ...obj }; + const host_filter = {}; + value.split(' ').forEach((item) => { + if (item.includes('host_filter')) { + host_filter.host_filter = item.slice('host_filter='.length); + } + }); + + Object.keys(clonedObj).forEach((key) => { + if (key.indexOf('host_filter') !== -1) { + delete clonedObj[key]; + } + }); + + return { + ...clonedObj, + ...host_filter, + }; +} diff --git a/awx/ui/src/components/Lookup/shared/HostFilterUtils.test.js b/awx/ui/src/components/Lookup/shared/HostFilterUtils.test.js index cb41fbde15..93a491d662 100644 --- a/awx/ui/src/components/Lookup/shared/HostFilterUtils.test.js +++ b/awx/ui/src/components/Lookup/shared/HostFilterUtils.test.js @@ -4,6 +4,7 @@ import { toHostFilter, toQueryString, toSearchParams, + modifyHostFilter, } from './HostFilterUtils'; const QS_CONFIG = { @@ -171,3 +172,34 @@ describe('removeDefaultParams', () => { }); }); }); + +describe('modifyHostFilter', () => { + test('should modify host_filter', () => { + const object = { + foo: ['bar', 'baz', 'qux'], + apat: 'lima', + page: 10, + order_by: '-name', + }; + expect( + modifyHostFilter( + 'host_filter=ansible_facts__ansible_lo__ipv6[]__scope="host"', + object + ) + ).toEqual({ + apat: 'lima', + foo: ['bar', 'baz', 'qux'], + host_filter: 'ansible_facts__ansible_lo__ipv6[]__scope="host"', + order_by: '-name', + page: 10, + }); + }); + test('should not modify host_filter', () => { + const object = { groups__name__icontains: '1' }; + expect( + modifyHostFilter('groups__name__icontains=1', { + groups__name__icontains: '1', + }) + ).toEqual(object); + }); +}); diff --git a/awx/ui/src/components/Search/AdvancedSearch.js b/awx/ui/src/components/Search/AdvancedSearch.js index 89a09528df..154da44d15 100644 --- a/awx/ui/src/components/Search/AdvancedSearch.js +++ b/awx/ui/src/components/Search/AdvancedSearch.js @@ -16,6 +16,7 @@ import { } from '@patternfly/react-core'; import { SearchIcon, QuestionCircleIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; +import { useLocation } from 'react-router-dom'; import { useConfig } from 'contexts/Config'; import getDocsBaseUrl from 'util/getDocsBaseUrl'; import { SearchableKeys } from 'types'; @@ -42,17 +43,48 @@ function AdvancedSearch({ maxSelectHeight, enableNegativeFiltering, enableRelatedFuzzyFiltering, + handleIsAnsibleFactsSelected, + isFilterCleared, }) { const relatedKeys = relatedSearchableKeys.filter( (sKey) => !searchableKeys.map(({ key }) => key).includes(sKey) ); - const [isPrefixDropdownOpen, setIsPrefixDropdownOpen] = 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 [isTextInputDisabled, setIsTextInputDisabled] = useState(false); + const { pathname, search } = useLocation(); + + useEffect(() => { + if (keySelection === 'ansible_facts') { + handleIsAnsibleFactsSelected(true); + setPrefixSelection(null); + } else { + handleIsAnsibleFactsSelected(false); + } + }, [keySelection]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (isFilterCleared && keySelection === 'ansible_facts') { + setIsTextInputDisabled(false); + } + }, [isFilterCleared, keySelection]); + + useEffect(() => { + if ( + (pathname.includes('edit') || pathname.includes('add')) && + keySelection === 'ansible_facts' && + search.includes('ansible_facts') + ) { + setIsTextInputDisabled(true); + } else { + setIsTextInputDisabled(false); + } + }, [keySelection, pathname, search]); + const config = useConfig(); const selectedKey = searchableKeys.find((k) => k.key === keySelection); @@ -64,7 +96,7 @@ function AdvancedSearch({ keySelection && !relatedSearchKeySelected ? selectedKey?.type : null; useEffect(() => { - if (relatedSearchKeySelected) { + if (relatedSearchKeySelected && keySelection !== 'ansible_facts') { setLookupSelection('name__icontains'); } else { setLookupSelection(null); @@ -86,7 +118,12 @@ function AdvancedSearch({ const actualSearchKey = [actualPrefix, keySelection, lookupSelection] .filter((val) => !!val) .join('__'); - onSearch(actualSearchKey, searchValue); + if (keySelection === 'ansible_facts') { + const ansibleFactValue = `${actualSearchKey}__${searchValue}`; + onSearch('host_filter', ansibleFactValue); + } else { + onSearch(actualSearchKey, searchValue); + } setSearchValue(''); } }; @@ -137,17 +174,74 @@ function AdvancedSearch({ ); + const renderLookupType = () => { + if (keySelection === 'ansible_facts') return null; + + return relatedSearchKeySelected ? ( + + ) : ( + + ); + }; + + const renderTextInput = () => { + if (isTextInputDisabled) { + return ( + + + + ); + } + + return ( + + ); + }; + + const renderLookupSelection = () => { + if (keySelection === 'ansible_facts') return null; + return lookupSelection === 'search' ? ( + + {renderSetType()} + + ) : ( + renderSetType() + ); + }; + return ( - {lookupSelection === 'search' ? ( - - {renderSetType()} - - ) : ( - renderSetType() - )} + {renderLookupSelection()}