mirror of
https://github.com/ansible/awx.git
synced 2026-01-10 15:32:07 -03:30
Merge pull request #11592 from nixocio/ui_issue_11017_utils
Modify usage of ansible_facts on advanced search
This commit is contained in:
commit
f085afd92f
@ -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}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
{sortColumns && (
|
||||
|
||||
@ -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,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
|
||||
@ -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={
|
||||
<Popover
|
||||
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
|
||||
examples. Refer to the Ansible Tower documentation for further syntax and
|
||||
examples.`}
|
||||
@ -363,6 +389,26 @@ function HostFilterLookup({
|
||||
]}
|
||||
>
|
||||
<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
|
||||
contentError={error}
|
||||
hasContentLoading={isLoading}
|
||||
@ -383,6 +429,7 @@ function HostFilterLookup({
|
||||
fillWidth
|
||||
enableNegativeFiltering={enableNegativeFiltering}
|
||||
enableRelatedFuzzyFiltering={enableRelatedFuzzyFiltering}
|
||||
handleIsAnsibleFactsSelected={setIsAnsibleFactsSelected}
|
||||
/>
|
||||
)}
|
||||
toolbarSearchColumns={searchColumns}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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({
|
||||
</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 (
|
||||
<AdvancedGroup>
|
||||
{lookupSelection === 'search' ? (
|
||||
<Tooltip
|
||||
content={t`Set type disabled for related search field fuzzy searches`}
|
||||
>
|
||||
{renderSetType()}
|
||||
</Tooltip>
|
||||
) : (
|
||||
renderSetType()
|
||||
)}
|
||||
{renderLookupSelection()}
|
||||
<Select
|
||||
ouiaId="set-key-typeahead"
|
||||
aria-label={t`Key select`}
|
||||
@ -200,31 +294,10 @@ function AdvancedSearch({
|
||||
: []),
|
||||
]}
|
||||
</Select>
|
||||
{relatedSearchKeySelected ? (
|
||||
<RelatedLookupTypeInput
|
||||
value={lookupSelection}
|
||||
setValue={setLookupSelection}
|
||||
maxSelectHeight={maxSelectHeight}
|
||||
enableFuzzyFiltering={enableRelatedFuzzyFiltering}
|
||||
/>
|
||||
) : (
|
||||
<LookupTypeInput
|
||||
value={lookupSelection}
|
||||
type={lookupKeyType}
|
||||
setValue={setLookupSelection}
|
||||
maxSelectHeight={maxSelectHeight}
|
||||
/>
|
||||
)}
|
||||
{renderLookupType()}
|
||||
|
||||
<InputGroup>
|
||||
<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}
|
||||
/>
|
||||
{renderTextInput()}
|
||||
<div css={!searchValue && `cursor:not-allowed`}>
|
||||
<Button
|
||||
ouiaId="advanced-search-text-input"
|
||||
@ -259,6 +332,7 @@ AdvancedSearch.propTypes = {
|
||||
maxSelectHeight: string,
|
||||
enableNegativeFiltering: bool,
|
||||
enableRelatedFuzzyFiltering: bool,
|
||||
handleIsAnsibleFactsSelected: func,
|
||||
};
|
||||
|
||||
AdvancedSearch.defaultProps = {
|
||||
@ -267,6 +341,7 @@ AdvancedSearch.defaultProps = {
|
||||
maxSelectHeight: '300px',
|
||||
enableNegativeFiltering: true,
|
||||
enableRelatedFuzzyFiltering: true,
|
||||
handleIsAnsibleFactsSelected: () => {},
|
||||
};
|
||||
|
||||
export default AdvancedSearch;
|
||||
|
||||
@ -44,6 +44,8 @@ function Search({
|
||||
maxSelectHeight,
|
||||
enableNegativeFiltering,
|
||||
enableRelatedFuzzyFiltering,
|
||||
handleIsAnsibleFactsSelected,
|
||||
isFilterCleared,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false);
|
||||
@ -96,11 +98,14 @@ function Search({
|
||||
}
|
||||
};
|
||||
|
||||
const chipsByKey = getChipsByKey(
|
||||
parseQueryString(qsConfig, location.search),
|
||||
columns,
|
||||
qsConfig
|
||||
);
|
||||
const params = parseQueryString(qsConfig, location.search);
|
||||
if (params?.host_filter) {
|
||||
params.ansible_facts = params.host_filter.substring(
|
||||
'ansible_facts__'.length
|
||||
);
|
||||
delete params.host_filter;
|
||||
}
|
||||
const chipsByKey = getChipsByKey(params, columns, qsConfig);
|
||||
|
||||
const { name: searchColumnName } = columns.find(
|
||||
({ key }) => key === searchKey
|
||||
@ -161,6 +166,8 @@ function Search({
|
||||
maxSelectHeight={maxSelectHeight}
|
||||
enableNegativeFiltering={enableNegativeFiltering}
|
||||
enableRelatedFuzzyFiltering={enableRelatedFuzzyFiltering}
|
||||
handleIsAnsibleFactsSelected={handleIsAnsibleFactsSelected}
|
||||
isFilterCleared={isFilterCleared}
|
||||
/>
|
||||
)) ||
|
||||
(options && (
|
||||
@ -258,7 +265,11 @@ function Search({
|
||||
chips={chipsByKey[leftoverKey] ? chipsByKey[leftoverKey].chips : []}
|
||||
deleteChip={(unusedKey, chip) => {
|
||||
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={
|
||||
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 nonDefaultSearchKeys = Object.keys(nonDefaultSearchParams);
|
||||
return (
|
||||
@ -185,9 +193,11 @@ function HostList() {
|
||||
? [
|
||||
<SmartInventoryButton
|
||||
hasInvalidKeys={hasInvalidHostFilterKeys()}
|
||||
hasAnsibleFactsKeys={hasAnsibleFactsKeys()}
|
||||
isDisabled={
|
||||
Object.keys(nonDefaultSearchParams).length === 0 ||
|
||||
hasInvalidHostFilterKeys()
|
||||
hasInvalidHostFilterKeys() ||
|
||||
hasAnsibleFactsKeys()
|
||||
}
|
||||
onClick={() => handleSmartInventoryClick()}
|
||||
/>,
|
||||
|
||||
@ -287,6 +287,25 @@ describe('<HostList />', () => {
|
||||
).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 () => {
|
||||
let wrapper;
|
||||
const history = createMemoryHistory({
|
||||
|
||||
@ -4,13 +4,21 @@ import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useKebabifiedMenu } from 'contexts/Kebabified';
|
||||
|
||||
function SmartInventoryButton({ onClick, isDisabled, hasInvalidKeys }) {
|
||||
function SmartInventoryButton({
|
||||
onClick,
|
||||
isDisabled,
|
||||
hasInvalidKeys,
|
||||
hasAnsibleFactsKeys,
|
||||
}) {
|
||||
const { isKebabified } = useKebabifiedMenu();
|
||||
|
||||
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 (hasAnsibleFactsKeys) {
|
||||
return t`To create a smart inventory using ansible facts, go to the smart inventory screen.`;
|
||||
}
|
||||
if (isDisabled) {
|
||||
return t`Enter at least one search filter to create a new Smart Inventory`;
|
||||
}
|
||||
@ -60,11 +68,13 @@ SmartInventoryButton.propTypes = {
|
||||
hasInvalidKeys: bool,
|
||||
isDisabled: bool,
|
||||
onClick: func.isRequired,
|
||||
hasAnsibleFactsKeys: bool,
|
||||
};
|
||||
|
||||
SmartInventoryButton.defaultProps = {
|
||||
hasInvalidKeys: false,
|
||||
isDisabled: false,
|
||||
hasAnsibleFactsKeys: false,
|
||||
};
|
||||
|
||||
export default SmartInventoryButton;
|
||||
|
||||
@ -5,6 +5,7 @@ import { CardBody } from 'components/Card';
|
||||
import useRequest from 'hooks/useRequest';
|
||||
import { InventoriesAPI } from 'api';
|
||||
import SmartInventoryForm from '../shared/SmartInventoryForm';
|
||||
import parseHostFilter from '../shared/utils';
|
||||
|
||||
function SmartInventoryAdd() {
|
||||
const history = useHistory();
|
||||
@ -30,7 +31,9 @@ function SmartInventoryAdd() {
|
||||
);
|
||||
|
||||
const handleSubmit = async (form) => {
|
||||
const { instance_groups, organization, ...remainingForm } = form;
|
||||
const modifiedForm = parseHostFilter(form);
|
||||
|
||||
const { instance_groups, organization, ...remainingForm } = modifiedForm;
|
||||
|
||||
await submitRequest(
|
||||
{
|
||||
|
||||
@ -89,6 +89,26 @@ describe('<SmartInventoryAdd />', () => {
|
||||
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 () => {
|
||||
expect(history.location.pathname).toEqual(
|
||||
'/inventories/smart_inventory/1/details'
|
||||
|
||||
@ -7,6 +7,7 @@ import { CardBody } from 'components/Card';
|
||||
import ContentError from 'components/ContentError';
|
||||
import ContentLoading from 'components/ContentLoading';
|
||||
import SmartInventoryForm from '../shared/SmartInventoryForm';
|
||||
import parseHostFilter from '../shared/utils';
|
||||
|
||||
function SmartInventoryEdit({ inventory }) {
|
||||
const history = useHistory();
|
||||
@ -60,7 +61,8 @@ function SmartInventoryEdit({ inventory }) {
|
||||
}, [submitResult, detailsUrl, history]);
|
||||
|
||||
const handleSubmit = async (form) => {
|
||||
const { instance_groups, organization, ...remainingForm } = form;
|
||||
const modifiedForm = parseHostFilter(form);
|
||||
const { instance_groups, organization, ...remainingForm } = modifiedForm;
|
||||
|
||||
await submitRequest(
|
||||
{
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export { default } from './Inventories';
|
||||
export { default as parseHostFilter } from './shared/utils';
|
||||
|
||||
@ -111,10 +111,18 @@ function SmartInventoryForm({
|
||||
const queryParams = new URLSearchParams(search);
|
||||
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 = {
|
||||
description: inventory.description || '',
|
||||
host_filter:
|
||||
inventory.host_filter ||
|
||||
addHostFilter(inventory.host_filter) ||
|
||||
(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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user