Merge pull request #11592 from nixocio/ui_issue_11017_utils

Modify usage of ansible_facts on advanced search
This commit is contained in:
Kersom
2022-02-14 10:30:45 -05:00
committed by GitHub
18 changed files with 362 additions and 53 deletions

View File

@@ -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 && (

View File

@@ -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,
})} })}
</> </>
)} )}

View File

@@ -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');
}); });

View File

@@ -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}

View File

@@ -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,
};
}

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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]

View File

@@ -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()}
/>, />,

View File

@@ -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({

View File

@@ -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;

View File

@@ -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(
{ {

View File

@@ -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'

View File

@@ -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(
{ {

View File

@@ -1 +1,2 @@
export { default } from './Inventories'; export { default } from './Inventories';
export { default as parseHostFilter } from './shared/utils';

View File

@@ -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))
: ''), : ''),

View 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;

View 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',
});
});
});