mirror of
https://github.com/ansible/awx.git
synced 2026-03-09 13:39:27 -02:30
Merge pull request #11592 from nixocio/ui_issue_11017_utils
Modify usage of ansible_facts on advanced search
This commit is contained in:
@@ -55,6 +55,8 @@ function DataListToolbar({
|
|||||||
pagination,
|
pagination,
|
||||||
enableNegativeFiltering,
|
enableNegativeFiltering,
|
||||||
enableRelatedFuzzyFiltering,
|
enableRelatedFuzzyFiltering,
|
||||||
|
handleIsAnsibleFactsSelected,
|
||||||
|
isFilterCleared,
|
||||||
}) {
|
}) {
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
const showExpandCollapse = onCompact && onExpand;
|
const showExpandCollapse = onCompact && onExpand;
|
||||||
@@ -143,6 +145,8 @@ function DataListToolbar({
|
|||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
enableNegativeFiltering={enableNegativeFiltering}
|
enableNegativeFiltering={enableNegativeFiltering}
|
||||||
enableRelatedFuzzyFiltering={enableRelatedFuzzyFiltering}
|
enableRelatedFuzzyFiltering={enableRelatedFuzzyFiltering}
|
||||||
|
handleIsAnsibleFactsSelected={handleIsAnsibleFactsSelected}
|
||||||
|
isFilterCleared={isFilterCleared}
|
||||||
/>
|
/>
|
||||||
</ToolbarItem>
|
</ToolbarItem>
|
||||||
{sortColumns && (
|
{sortColumns && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable react/jsx-no-useless-fragment */
|
/* eslint-disable react/jsx-no-useless-fragment */
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
import { useHistory, useLocation } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
@@ -27,6 +27,7 @@ const EmptyStateControlsWrapper = styled.div`
|
|||||||
`;
|
`;
|
||||||
function ListHeader(props) {
|
function ListHeader(props) {
|
||||||
const { search, pathname } = useLocation();
|
const { search, pathname } = useLocation();
|
||||||
|
const [isFilterCleared, setIsFilterCleared] = useState(false);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const {
|
const {
|
||||||
emptyStateControls,
|
emptyStateControls,
|
||||||
@@ -73,6 +74,7 @@ function ListHeader(props) {
|
|||||||
delete oldParams.page_size;
|
delete oldParams.page_size;
|
||||||
delete oldParams.order_by;
|
delete oldParams.order_by;
|
||||||
const qs = updateQueryString(qsConfig, search, oldParams);
|
const qs = updateQueryString(qsConfig, search, oldParams);
|
||||||
|
setIsFilterCleared(true);
|
||||||
pushHistoryState(qs);
|
pushHistoryState(qs);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,6 +122,7 @@ function ListHeader(props) {
|
|||||||
clearAllFilters: handleRemoveAll,
|
clearAllFilters: handleRemoveAll,
|
||||||
qsConfig,
|
qsConfig,
|
||||||
pagination,
|
pagination,
|
||||||
|
isFilterCleared,
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
import ListHeader from './ListHeader';
|
import ListHeader from './ListHeader';
|
||||||
@@ -74,7 +75,9 @@ describe('ListHeader', () => {
|
|||||||
|
|
||||||
expect(history.location.search).toEqual(query);
|
expect(history.location.search).toEqual(query);
|
||||||
const toolbar = wrapper.find('DataListToolbar');
|
const toolbar = wrapper.find('DataListToolbar');
|
||||||
toolbar.prop('clearAllFilters')();
|
act(() => {
|
||||||
|
toolbar.prop('clearAllFilters')();
|
||||||
|
});
|
||||||
expect(history.location.search).toEqual('?item.page_size=5');
|
expect(history.location.search).toEqual('?item.page_size=5');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import styled from 'styled-components';
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { SearchIcon } from '@patternfly/react-icons';
|
import { SearchIcon } from '@patternfly/react-icons';
|
||||||
import {
|
import {
|
||||||
|
Alert as PFAlert,
|
||||||
Button,
|
Button,
|
||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
Chip,
|
Chip,
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { HostsAPI } from 'api';
|
import { HostsAPI } from 'api';
|
||||||
import { getQSConfig, mergeParams, parseQueryString } from 'util/qs';
|
import { getQSConfig, mergeParams, parseQueryString } from 'util/qs';
|
||||||
|
import getDocsBaseUrl from 'util/getDocsBaseUrl';
|
||||||
|
import { useConfig } from 'contexts/Config';
|
||||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||||
import ChipGroup from '../ChipGroup';
|
import ChipGroup from '../ChipGroup';
|
||||||
import Popover from '../Popover';
|
import Popover from '../Popover';
|
||||||
@@ -33,8 +36,15 @@ import {
|
|||||||
toHostFilter,
|
toHostFilter,
|
||||||
toQueryString,
|
toQueryString,
|
||||||
toSearchParams,
|
toSearchParams,
|
||||||
|
modifyHostFilter,
|
||||||
} from './shared/HostFilterUtils';
|
} from './shared/HostFilterUtils';
|
||||||
|
|
||||||
|
const Alert = styled(PFAlert)`
|
||||||
|
&& {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const ChipHolder = styled.div`
|
const ChipHolder = styled.div`
|
||||||
&& {
|
&& {
|
||||||
--pf-c-form-control--Height: auto;
|
--pf-c-form-control--Height: auto;
|
||||||
@@ -131,7 +141,10 @@ function HostFilterLookup({
|
|||||||
const [chips, setChips] = useState({});
|
const [chips, setChips] = useState({});
|
||||||
const [queryString, setQueryString] = useState('');
|
const [queryString, setQueryString] = useState('');
|
||||||
const { isModalOpen, toggleModal, closeModal } = useModal();
|
const { isModalOpen, toggleModal, closeModal } = useModal();
|
||||||
|
const [isAnsibleFactsSelected, setIsAnsibleFactsSelected] = useState(false);
|
||||||
|
|
||||||
const searchColumns = buildSearchColumns();
|
const searchColumns = buildSearchColumns();
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
const parseRelatedSearchFields = (searchFields) => {
|
const parseRelatedSearchFields = (searchFields) => {
|
||||||
if (searchFields.indexOf('__search') !== -1) {
|
if (searchFields.indexOf('__search') !== -1) {
|
||||||
@@ -185,8 +198,10 @@ function HostFilterLookup({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const filters = toSearchParams(value);
|
const filters = toSearchParams(value);
|
||||||
setQueryString(toQueryString(QS_CONFIG, filters));
|
let modifiedFilters = modifyHostFilter(value, filters);
|
||||||
setChips(buildChips(filters));
|
setQueryString(toQueryString(QS_CONFIG, modifiedFilters));
|
||||||
|
modifiedFilters = removeHostFilter(modifiedFilters);
|
||||||
|
setChips(buildChips(modifiedFilters));
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
function qsToHostFilter(qs) {
|
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 = {}) {
|
function buildChips(filter = {}) {
|
||||||
const inputGroupChips = Object.keys(filter).reduce((obj, param) => {
|
const inputGroupChips = Object.keys(filter).reduce((obj, param) => {
|
||||||
const parsedKey = param.replace('or__', '');
|
const parsedKey = param.replace('or__', '');
|
||||||
@@ -320,7 +346,7 @@ function HostFilterLookup({
|
|||||||
labelIcon={
|
labelIcon={
|
||||||
<Popover
|
<Popover
|
||||||
content={t`Populate the hosts for this inventory by using a search
|
content={t`Populate the hosts for this inventory by using a search
|
||||||
filter. Example: ansible_facts.ansible_distribution:"RedHat".
|
filter. Example: ansible_facts__ansible_distribution:"RedHat".
|
||||||
Refer to the documentation for further syntax and
|
Refer to the documentation for further syntax and
|
||||||
examples. Refer to the Ansible Tower documentation for further syntax and
|
examples. Refer to the Ansible Tower documentation for further syntax and
|
||||||
examples.`}
|
examples.`}
|
||||||
@@ -363,6 +389,26 @@ function HostFilterLookup({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<ModalList>
|
<ModalList>
|
||||||
|
{isAnsibleFactsSelected && (
|
||||||
|
<Alert
|
||||||
|
variant="info"
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
{t`Searching by ansible_facts requires special syntax. Refer to the`}{' '}
|
||||||
|
<a
|
||||||
|
href={`${getDocsBaseUrl(
|
||||||
|
config
|
||||||
|
)}/html/userguide/inventories.html#smart-host-filter`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t`documentation`}
|
||||||
|
</a>{' '}
|
||||||
|
{t`for more info.`}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<PaginatedTable
|
<PaginatedTable
|
||||||
contentError={error}
|
contentError={error}
|
||||||
hasContentLoading={isLoading}
|
hasContentLoading={isLoading}
|
||||||
@@ -383,6 +429,7 @@ function HostFilterLookup({
|
|||||||
fillWidth
|
fillWidth
|
||||||
enableNegativeFiltering={enableNegativeFiltering}
|
enableNegativeFiltering={enableNegativeFiltering}
|
||||||
enableRelatedFuzzyFiltering={enableRelatedFuzzyFiltering}
|
enableRelatedFuzzyFiltering={enableRelatedFuzzyFiltering}
|
||||||
|
handleIsAnsibleFactsSelected={setIsAnsibleFactsSelected}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
toolbarSearchColumns={searchColumns}
|
toolbarSearchColumns={searchColumns}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export function toSearchParams(string = '') {
|
|||||||
const unescapeString = (v) =>
|
const unescapeString = (v) =>
|
||||||
// This is necessary when editing a string that was initially
|
// This is necessary when editing a string that was initially
|
||||||
// escaped to allow white space
|
// escaped to allow white space
|
||||||
v.replace(/"/g, '');
|
v ? v.replace(/"/g, '') : '';
|
||||||
|
|
||||||
return orArr
|
return orArr
|
||||||
.join(' and ')
|
.join(' and ')
|
||||||
.split(/ and | or /)
|
.split(/ and | or /)
|
||||||
@@ -159,3 +160,32 @@ export function removeDefaultParams(config, obj = {}) {
|
|||||||
});
|
});
|
||||||
return clonedObj;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
toHostFilter,
|
toHostFilter,
|
||||||
toQueryString,
|
toQueryString,
|
||||||
toSearchParams,
|
toSearchParams,
|
||||||
|
modifyHostFilter,
|
||||||
} from './HostFilterUtils';
|
} from './HostFilterUtils';
|
||||||
|
|
||||||
const QS_CONFIG = {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { SearchIcon, QuestionCircleIcon } from '@patternfly/react-icons';
|
import { SearchIcon, QuestionCircleIcon } from '@patternfly/react-icons';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useConfig } from 'contexts/Config';
|
import { useConfig } from 'contexts/Config';
|
||||||
import getDocsBaseUrl from 'util/getDocsBaseUrl';
|
import getDocsBaseUrl from 'util/getDocsBaseUrl';
|
||||||
import { SearchableKeys } from 'types';
|
import { SearchableKeys } from 'types';
|
||||||
@@ -42,17 +43,48 @@ function AdvancedSearch({
|
|||||||
maxSelectHeight,
|
maxSelectHeight,
|
||||||
enableNegativeFiltering,
|
enableNegativeFiltering,
|
||||||
enableRelatedFuzzyFiltering,
|
enableRelatedFuzzyFiltering,
|
||||||
|
handleIsAnsibleFactsSelected,
|
||||||
|
isFilterCleared,
|
||||||
}) {
|
}) {
|
||||||
const relatedKeys = relatedSearchableKeys.filter(
|
const relatedKeys = relatedSearchableKeys.filter(
|
||||||
(sKey) => !searchableKeys.map(({ key }) => key).includes(sKey)
|
(sKey) => !searchableKeys.map(({ key }) => key).includes(sKey)
|
||||||
);
|
);
|
||||||
|
|
||||||
const [isPrefixDropdownOpen, setIsPrefixDropdownOpen] = useState(false);
|
const [isPrefixDropdownOpen, setIsPrefixDropdownOpen] = useState(false);
|
||||||
const [isKeyDropdownOpen, setIsKeyDropdownOpen] = useState(false);
|
const [isKeyDropdownOpen, setIsKeyDropdownOpen] = useState(false);
|
||||||
const [prefixSelection, setPrefixSelection] = useState(null);
|
const [prefixSelection, setPrefixSelection] = useState(null);
|
||||||
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 [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 config = useConfig();
|
||||||
|
|
||||||
const selectedKey = searchableKeys.find((k) => k.key === keySelection);
|
const selectedKey = searchableKeys.find((k) => k.key === keySelection);
|
||||||
@@ -64,7 +96,7 @@ function AdvancedSearch({
|
|||||||
keySelection && !relatedSearchKeySelected ? selectedKey?.type : null;
|
keySelection && !relatedSearchKeySelected ? selectedKey?.type : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (relatedSearchKeySelected) {
|
if (relatedSearchKeySelected && keySelection !== 'ansible_facts') {
|
||||||
setLookupSelection('name__icontains');
|
setLookupSelection('name__icontains');
|
||||||
} else {
|
} else {
|
||||||
setLookupSelection(null);
|
setLookupSelection(null);
|
||||||
@@ -86,7 +118,12 @@ function AdvancedSearch({
|
|||||||
const actualSearchKey = [actualPrefix, keySelection, lookupSelection]
|
const actualSearchKey = [actualPrefix, keySelection, lookupSelection]
|
||||||
.filter((val) => !!val)
|
.filter((val) => !!val)
|
||||||
.join('__');
|
.join('__');
|
||||||
onSearch(actualSearchKey, searchValue);
|
if (keySelection === 'ansible_facts') {
|
||||||
|
const ansibleFactValue = `${actualSearchKey}__${searchValue}`;
|
||||||
|
onSearch('host_filter', ansibleFactValue);
|
||||||
|
} else {
|
||||||
|
onSearch(actualSearchKey, searchValue);
|
||||||
|
}
|
||||||
setSearchValue('');
|
setSearchValue('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -137,17 +174,74 @@ function AdvancedSearch({
|
|||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderLookupType = () => {
|
||||||
|
if (keySelection === 'ansible_facts') return null;
|
||||||
|
|
||||||
|
return relatedSearchKeySelected ? (
|
||||||
|
<RelatedLookupTypeInput
|
||||||
|
value={lookupSelection}
|
||||||
|
setValue={setLookupSelection}
|
||||||
|
maxSelectHeight={maxSelectHeight}
|
||||||
|
enableFuzzyFiltering={enableRelatedFuzzyFiltering}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LookupTypeInput
|
||||||
|
value={lookupSelection}
|
||||||
|
type={lookupKeyType}
|
||||||
|
setValue={setLookupSelection}
|
||||||
|
maxSelectHeight={maxSelectHeight}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTextInput = () => {
|
||||||
|
if (isTextInputDisabled) {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content={t`Remove the current search related to ansible facts to enable another search using this key.`}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
data-cy="advanced-search-text-input"
|
||||||
|
type="search"
|
||||||
|
aria-label={t`Advanced search value input`}
|
||||||
|
isDisabled={!keySelection || isTextInputDisabled}
|
||||||
|
value={(!keySelection && t`First, select a key`) || searchValue}
|
||||||
|
onChange={setSearchValue}
|
||||||
|
onKeyDown={handleAdvancedTextKeyDown}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
data-cy="advanced-search-text-input"
|
||||||
|
type="search"
|
||||||
|
aria-label={t`Advanced search value input`}
|
||||||
|
isDisabled={!keySelection}
|
||||||
|
value={(!keySelection && t`First, select a key`) || searchValue}
|
||||||
|
onChange={setSearchValue}
|
||||||
|
onKeyDown={handleAdvancedTextKeyDown}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLookupSelection = () => {
|
||||||
|
if (keySelection === 'ansible_facts') return null;
|
||||||
|
return lookupSelection === 'search' ? (
|
||||||
|
<Tooltip
|
||||||
|
content={t`Set type disabled for related search field fuzzy searches`}
|
||||||
|
>
|
||||||
|
{renderSetType()}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
renderSetType()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdvancedGroup>
|
<AdvancedGroup>
|
||||||
{lookupSelection === 'search' ? (
|
{renderLookupSelection()}
|
||||||
<Tooltip
|
|
||||||
content={t`Set type disabled for related search field fuzzy searches`}
|
|
||||||
>
|
|
||||||
{renderSetType()}
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
renderSetType()
|
|
||||||
)}
|
|
||||||
<Select
|
<Select
|
||||||
ouiaId="set-key-typeahead"
|
ouiaId="set-key-typeahead"
|
||||||
aria-label={t`Key select`}
|
aria-label={t`Key select`}
|
||||||
@@ -200,31 +294,10 @@ function AdvancedSearch({
|
|||||||
: []),
|
: []),
|
||||||
]}
|
]}
|
||||||
</Select>
|
</Select>
|
||||||
{relatedSearchKeySelected ? (
|
{renderLookupType()}
|
||||||
<RelatedLookupTypeInput
|
|
||||||
value={lookupSelection}
|
|
||||||
setValue={setLookupSelection}
|
|
||||||
maxSelectHeight={maxSelectHeight}
|
|
||||||
enableFuzzyFiltering={enableRelatedFuzzyFiltering}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LookupTypeInput
|
|
||||||
value={lookupSelection}
|
|
||||||
type={lookupKeyType}
|
|
||||||
setValue={setLookupSelection}
|
|
||||||
maxSelectHeight={maxSelectHeight}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<TextInput
|
{renderTextInput()}
|
||||||
data-cy="advanced-search-text-input"
|
|
||||||
type="search"
|
|
||||||
aria-label={t`Advanced search value input`}
|
|
||||||
isDisabled={!keySelection}
|
|
||||||
value={(!keySelection && t`First, select a key`) || searchValue}
|
|
||||||
onChange={setSearchValue}
|
|
||||||
onKeyDown={handleAdvancedTextKeyDown}
|
|
||||||
/>
|
|
||||||
<div css={!searchValue && `cursor:not-allowed`}>
|
<div css={!searchValue && `cursor:not-allowed`}>
|
||||||
<Button
|
<Button
|
||||||
ouiaId="advanced-search-text-input"
|
ouiaId="advanced-search-text-input"
|
||||||
@@ -259,6 +332,7 @@ AdvancedSearch.propTypes = {
|
|||||||
maxSelectHeight: string,
|
maxSelectHeight: string,
|
||||||
enableNegativeFiltering: bool,
|
enableNegativeFiltering: bool,
|
||||||
enableRelatedFuzzyFiltering: bool,
|
enableRelatedFuzzyFiltering: bool,
|
||||||
|
handleIsAnsibleFactsSelected: func,
|
||||||
};
|
};
|
||||||
|
|
||||||
AdvancedSearch.defaultProps = {
|
AdvancedSearch.defaultProps = {
|
||||||
@@ -267,6 +341,7 @@ AdvancedSearch.defaultProps = {
|
|||||||
maxSelectHeight: '300px',
|
maxSelectHeight: '300px',
|
||||||
enableNegativeFiltering: true,
|
enableNegativeFiltering: true,
|
||||||
enableRelatedFuzzyFiltering: true,
|
enableRelatedFuzzyFiltering: true,
|
||||||
|
handleIsAnsibleFactsSelected: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdvancedSearch;
|
export default AdvancedSearch;
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ function Search({
|
|||||||
maxSelectHeight,
|
maxSelectHeight,
|
||||||
enableNegativeFiltering,
|
enableNegativeFiltering,
|
||||||
enableRelatedFuzzyFiltering,
|
enableRelatedFuzzyFiltering,
|
||||||
|
handleIsAnsibleFactsSelected,
|
||||||
|
isFilterCleared,
|
||||||
}) {
|
}) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false);
|
const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false);
|
||||||
@@ -96,11 +98,14 @@ function Search({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const chipsByKey = getChipsByKey(
|
const params = parseQueryString(qsConfig, location.search);
|
||||||
parseQueryString(qsConfig, location.search),
|
if (params?.host_filter) {
|
||||||
columns,
|
params.ansible_facts = params.host_filter.substring(
|
||||||
qsConfig
|
'ansible_facts__'.length
|
||||||
);
|
);
|
||||||
|
delete params.host_filter;
|
||||||
|
}
|
||||||
|
const chipsByKey = getChipsByKey(params, columns, qsConfig);
|
||||||
|
|
||||||
const { name: searchColumnName } = columns.find(
|
const { name: searchColumnName } = columns.find(
|
||||||
({ key }) => key === searchKey
|
({ key }) => key === searchKey
|
||||||
@@ -161,6 +166,8 @@ function Search({
|
|||||||
maxSelectHeight={maxSelectHeight}
|
maxSelectHeight={maxSelectHeight}
|
||||||
enableNegativeFiltering={enableNegativeFiltering}
|
enableNegativeFiltering={enableNegativeFiltering}
|
||||||
enableRelatedFuzzyFiltering={enableRelatedFuzzyFiltering}
|
enableRelatedFuzzyFiltering={enableRelatedFuzzyFiltering}
|
||||||
|
handleIsAnsibleFactsSelected={handleIsAnsibleFactsSelected}
|
||||||
|
isFilterCleared={isFilterCleared}
|
||||||
/>
|
/>
|
||||||
)) ||
|
)) ||
|
||||||
(options && (
|
(options && (
|
||||||
@@ -258,7 +265,11 @@ function Search({
|
|||||||
chips={chipsByKey[leftoverKey] ? chipsByKey[leftoverKey].chips : []}
|
chips={chipsByKey[leftoverKey] ? chipsByKey[leftoverKey].chips : []}
|
||||||
deleteChip={(unusedKey, chip) => {
|
deleteChip={(unusedKey, chip) => {
|
||||||
const [columnKey, ...value] = chip.key.split(':');
|
const [columnKey, ...value] = chip.key.split(':');
|
||||||
onRemove(columnKey, value.join(':'));
|
if (columnKey === 'ansible_facts') {
|
||||||
|
onRemove('host_filter', `${columnKey}__${value}`);
|
||||||
|
} else {
|
||||||
|
onRemove(columnKey, value.join(':'));
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
categoryName={
|
categoryName={
|
||||||
chipsByKey[leftoverKey]
|
chipsByKey[leftoverKey]
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ function HostList() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasAnsibleFactsKeys = () => {
|
||||||
|
const nonDefaultSearchValues = Object.values(nonDefaultSearchParams);
|
||||||
|
return (
|
||||||
|
nonDefaultSearchValues.filter((value) => value.includes('ansible_facts'))
|
||||||
|
.length > 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const hasInvalidHostFilterKeys = () => {
|
const hasInvalidHostFilterKeys = () => {
|
||||||
const nonDefaultSearchKeys = Object.keys(nonDefaultSearchParams);
|
const nonDefaultSearchKeys = Object.keys(nonDefaultSearchParams);
|
||||||
return (
|
return (
|
||||||
@@ -185,9 +193,11 @@ function HostList() {
|
|||||||
? [
|
? [
|
||||||
<SmartInventoryButton
|
<SmartInventoryButton
|
||||||
hasInvalidKeys={hasInvalidHostFilterKeys()}
|
hasInvalidKeys={hasInvalidHostFilterKeys()}
|
||||||
|
hasAnsibleFactsKeys={hasAnsibleFactsKeys()}
|
||||||
isDisabled={
|
isDisabled={
|
||||||
Object.keys(nonDefaultSearchParams).length === 0 ||
|
Object.keys(nonDefaultSearchParams).length === 0 ||
|
||||||
hasInvalidHostFilterKeys()
|
hasInvalidHostFilterKeys() ||
|
||||||
|
hasAnsibleFactsKeys()
|
||||||
}
|
}
|
||||||
onClick={() => handleSmartInventoryClick()}
|
onClick={() => handleSmartInventoryClick()}
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
@@ -287,6 +287,25 @@ describe('<HostList />', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Smart Inventory button should be disable to ansible facts search', async () => {
|
||||||
|
let wrapper;
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: [
|
||||||
|
'/hosts?host.host_filter=ansible_facts__ansible_date_time__weekday_number%3D"3"',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<HostList />, {
|
||||||
|
context: { router: { history } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForLoaded(wrapper);
|
||||||
|
expect(
|
||||||
|
wrapper.find('Button[aria-label="Smart Inventory"]').props().isDisabled
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('Clicking Smart Inventory button should navigate to smart inventory form with correct query param', async () => {
|
test('Clicking Smart Inventory button should navigate to smart inventory form with correct query param', async () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
const history = createMemoryHistory({
|
const history = createMemoryHistory({
|
||||||
|
|||||||
@@ -4,13 +4,21 @@ 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, hasInvalidKeys }) {
|
function SmartInventoryButton({
|
||||||
|
onClick,
|
||||||
|
isDisabled,
|
||||||
|
hasInvalidKeys,
|
||||||
|
hasAnsibleFactsKeys,
|
||||||
|
}) {
|
||||||
const { isKebabified } = useKebabifiedMenu();
|
const { isKebabified } = useKebabifiedMenu();
|
||||||
|
|
||||||
const renderTooltipContent = () => {
|
const renderTooltipContent = () => {
|
||||||
if (hasInvalidKeys) {
|
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.`;
|
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 (hasAnsibleFactsKeys) {
|
||||||
|
return t`To create a smart inventory using ansible facts, go to the smart inventory screen.`;
|
||||||
|
}
|
||||||
if (isDisabled) {
|
if (isDisabled) {
|
||||||
return t`Enter at least one search filter to create a new Smart Inventory`;
|
return t`Enter at least one search filter to create a new Smart Inventory`;
|
||||||
}
|
}
|
||||||
@@ -60,11 +68,13 @@ SmartInventoryButton.propTypes = {
|
|||||||
hasInvalidKeys: bool,
|
hasInvalidKeys: bool,
|
||||||
isDisabled: bool,
|
isDisabled: bool,
|
||||||
onClick: func.isRequired,
|
onClick: func.isRequired,
|
||||||
|
hasAnsibleFactsKeys: bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
SmartInventoryButton.defaultProps = {
|
SmartInventoryButton.defaultProps = {
|
||||||
hasInvalidKeys: false,
|
hasInvalidKeys: false,
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
|
hasAnsibleFactsKeys: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SmartInventoryButton;
|
export default SmartInventoryButton;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CardBody } from 'components/Card';
|
|||||||
import useRequest from 'hooks/useRequest';
|
import useRequest from 'hooks/useRequest';
|
||||||
import { InventoriesAPI } from 'api';
|
import { InventoriesAPI } from 'api';
|
||||||
import SmartInventoryForm from '../shared/SmartInventoryForm';
|
import SmartInventoryForm from '../shared/SmartInventoryForm';
|
||||||
|
import parseHostFilter from '../shared/utils';
|
||||||
|
|
||||||
function SmartInventoryAdd() {
|
function SmartInventoryAdd() {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
@@ -30,7 +31,9 @@ function SmartInventoryAdd() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = async (form) => {
|
const handleSubmit = async (form) => {
|
||||||
const { instance_groups, organization, ...remainingForm } = form;
|
const modifiedForm = parseHostFilter(form);
|
||||||
|
|
||||||
|
const { instance_groups, organization, ...remainingForm } = modifiedForm;
|
||||||
|
|
||||||
await submitRequest(
|
await submitRequest(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -89,6 +89,26 @@ describe('<SmartInventoryAdd />', () => {
|
|||||||
expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 2);
|
expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should parse host_filter with ansible facts', async () => {
|
||||||
|
const modifiedForm = {
|
||||||
|
...formData,
|
||||||
|
host_filter:
|
||||||
|
'host_filter=ansible_facts__ansible_env__PYTHONUNBUFFERED="true"',
|
||||||
|
};
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('SmartInventoryForm').invoke('onSubmit')(modifiedForm);
|
||||||
|
});
|
||||||
|
const { instance_groups, ...formRequest } = modifiedForm;
|
||||||
|
expect(InventoriesAPI.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(InventoriesAPI.create).toHaveBeenCalledWith({
|
||||||
|
...formRequest,
|
||||||
|
organization: formRequest.organization.id,
|
||||||
|
host_filter: 'ansible_facts__ansible_env__PYTHONUNBUFFERED="true"',
|
||||||
|
});
|
||||||
|
expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 2);
|
||||||
|
});
|
||||||
|
|
||||||
test('successful form submission should trigger redirect to details', async () => {
|
test('successful form submission should trigger redirect to details', async () => {
|
||||||
expect(history.location.pathname).toEqual(
|
expect(history.location.pathname).toEqual(
|
||||||
'/inventories/smart_inventory/1/details'
|
'/inventories/smart_inventory/1/details'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { CardBody } from 'components/Card';
|
|||||||
import ContentError from 'components/ContentError';
|
import ContentError from 'components/ContentError';
|
||||||
import ContentLoading from 'components/ContentLoading';
|
import ContentLoading from 'components/ContentLoading';
|
||||||
import SmartInventoryForm from '../shared/SmartInventoryForm';
|
import SmartInventoryForm from '../shared/SmartInventoryForm';
|
||||||
|
import parseHostFilter from '../shared/utils';
|
||||||
|
|
||||||
function SmartInventoryEdit({ inventory }) {
|
function SmartInventoryEdit({ inventory }) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
@@ -60,7 +61,8 @@ function SmartInventoryEdit({ inventory }) {
|
|||||||
}, [submitResult, detailsUrl, history]);
|
}, [submitResult, detailsUrl, history]);
|
||||||
|
|
||||||
const handleSubmit = async (form) => {
|
const handleSubmit = async (form) => {
|
||||||
const { instance_groups, organization, ...remainingForm } = form;
|
const modifiedForm = parseHostFilter(form);
|
||||||
|
const { instance_groups, organization, ...remainingForm } = modifiedForm;
|
||||||
|
|
||||||
await submitRequest(
|
await submitRequest(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { default } from './Inventories';
|
export { default } from './Inventories';
|
||||||
|
export { default as parseHostFilter } from './shared/utils';
|
||||||
|
|||||||
@@ -111,10 +111,18 @@ function SmartInventoryForm({
|
|||||||
const queryParams = new URLSearchParams(search);
|
const queryParams = new URLSearchParams(search);
|
||||||
const hostFilterFromParams = queryParams.get('host_filter');
|
const hostFilterFromParams = queryParams.get('host_filter');
|
||||||
|
|
||||||
|
function addHostFilter(string) {
|
||||||
|
if (!string) return null;
|
||||||
|
if (string.includes('ansible_facts') && !string.includes('host_filter')) {
|
||||||
|
return string.replace('ansible_facts', 'host_filter=ansible_facts');
|
||||||
|
}
|
||||||
|
return string;
|
||||||
|
}
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
description: inventory.description || '',
|
description: inventory.description || '',
|
||||||
host_filter:
|
host_filter:
|
||||||
inventory.host_filter ||
|
addHostFilter(inventory.host_filter) ||
|
||||||
(hostFilterFromParams
|
(hostFilterFromParams
|
||||||
? toHostFilter(toSearchParams(hostFilterFromParams))
|
? toHostFilter(toSearchParams(hostFilterFromParams))
|
||||||
: ''),
|
: ''),
|
||||||
|
|||||||
10
awx/ui/src/screens/Inventory/shared/utils.js
Normal file
10
awx/ui/src/screens/Inventory/shared/utils.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const parseHostFilter = (value) => {
|
||||||
|
if (value.host_filter && value.host_filter.includes('host_filter=')) {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
host_filter: value.host_filter.slice('host_filter='.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
export default parseHostFilter;
|
||||||
21
awx/ui/src/screens/Inventory/shared/utils.test.js
Normal file
21
awx/ui/src/screens/Inventory/shared/utils.test.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import parseHostFilter from './utils';
|
||||||
|
|
||||||
|
describe('parseHostFilter', () => {
|
||||||
|
test('parse host filter', () => {
|
||||||
|
expect(
|
||||||
|
parseHostFilter({
|
||||||
|
host_filter:
|
||||||
|
'host_filter=ansible_facts__ansible_processor[]="GenuineIntel"',
|
||||||
|
name: 'Foo',
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
host_filter: 'ansible_facts__ansible_processor[]="GenuineIntel"',
|
||||||
|
name: 'Foo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test('do not parse host filter', () => {
|
||||||
|
expect(parseHostFilter({ name: 'Foo' })).toEqual({
|
||||||
|
name: 'Foo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user