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
commit f085afd92f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 362 additions and 53 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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