mirror of
https://github.com/ansible/awx.git
synced 2026-02-25 06:56:00 -03:30
Merge pull request #8896 from mabashian/7700-smart-inv-button
Adds smart inventory button on host list Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
FormGroup,
|
FormGroup,
|
||||||
InputGroup,
|
InputGroup,
|
||||||
Modal,
|
Modal,
|
||||||
|
Tooltip,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import ChipGroup from '../ChipGroup';
|
import ChipGroup from '../ChipGroup';
|
||||||
import Popover from '../Popover';
|
import Popover from '../Popover';
|
||||||
@@ -243,6 +244,36 @@ function HostFilterLookup({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderLookup = () => (
|
||||||
|
<InputGroup onBlur={onBlur}>
|
||||||
|
<Button
|
||||||
|
aria-label={i18n._(t`Search`)}
|
||||||
|
id="host-filter"
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
variant={ButtonVariant.control}
|
||||||
|
>
|
||||||
|
<SearchIcon />
|
||||||
|
</Button>
|
||||||
|
<ChipHolder className="pf-c-form-control">
|
||||||
|
{searchColumns.map(({ name, key }) => (
|
||||||
|
<ChipGroup
|
||||||
|
categoryName={name}
|
||||||
|
key={name}
|
||||||
|
numChips={5}
|
||||||
|
totalChips={chips[key]?.chips?.length || 0}
|
||||||
|
>
|
||||||
|
{chips[key]?.chips?.map(chip => (
|
||||||
|
<Chip key={chip.key} isReadOnly>
|
||||||
|
{chip.node}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
))}
|
||||||
|
</ChipHolder>
|
||||||
|
</InputGroup>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
fieldId="host-filter"
|
fieldId="host-filter"
|
||||||
@@ -261,33 +292,17 @@ function HostFilterLookup({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<InputGroup onBlur={onBlur}>
|
{isDisabled ? (
|
||||||
<Button
|
<Tooltip
|
||||||
aria-label={i18n._(t`Search`)}
|
content={i18n._(
|
||||||
id="host-filter"
|
t`Please select an organization before editing the host filter`
|
||||||
isDisabled={isDisabled}
|
)}
|
||||||
onClick={handleOpenModal}
|
|
||||||
variant={ButtonVariant.control}
|
|
||||||
>
|
>
|
||||||
<SearchIcon />
|
{renderLookup()}
|
||||||
</Button>
|
</Tooltip>
|
||||||
<ChipHolder className="pf-c-form-control">
|
) : (
|
||||||
{searchColumns.map(({ name, key }) => (
|
renderLookup()
|
||||||
<ChipGroup
|
)}
|
||||||
categoryName={name}
|
|
||||||
key={name}
|
|
||||||
numChips={5}
|
|
||||||
totalChips={chips[key]?.chips?.length || 0}
|
|
||||||
>
|
|
||||||
{chips[key]?.chips?.map(chip => (
|
|
||||||
<Chip key={chip.key} isReadOnly>
|
|
||||||
{chip.node}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</ChipGroup>
|
|
||||||
))}
|
|
||||||
</ChipHolder>
|
|
||||||
</InputGroup>
|
|
||||||
<Modal
|
<Modal
|
||||||
aria-label={i18n._(t`Lookup modal`)}
|
aria-label={i18n._(t`Lookup modal`)}
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useLocation, useRouteMatch } from 'react-router-dom';
|
import { useHistory, useLocation, useRouteMatch } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Card, PageSection } from '@patternfly/react-core';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
@@ -13,9 +13,14 @@ import PaginatedDataList, {
|
|||||||
ToolbarDeleteButton,
|
ToolbarDeleteButton,
|
||||||
} from '../../../components/PaginatedDataList';
|
} from '../../../components/PaginatedDataList';
|
||||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
import {
|
||||||
|
encodeQueryString,
|
||||||
|
getQSConfig,
|
||||||
|
parseQueryString,
|
||||||
|
} from '../../../util/qs';
|
||||||
|
|
||||||
import HostListItem from './HostListItem';
|
import HostListItem from './HostListItem';
|
||||||
|
import SmartInventoryButton from './SmartInventoryButton';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('host', {
|
const QS_CONFIG = getQSConfig('host', {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -24,9 +29,21 @@ const QS_CONFIG = getQSConfig('host', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function HostList({ i18n }) {
|
function HostList({ i18n }) {
|
||||||
|
const history = useHistory();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
const [selected, setSelected] = useState([]);
|
const [selected, setSelected] = useState([]);
|
||||||
|
const parsedQueryStrings = parseQueryString(QS_CONFIG, location.search);
|
||||||
|
const nonDefaultSearchParams = {};
|
||||||
|
|
||||||
|
Object.keys(parsedQueryStrings).forEach(key => {
|
||||||
|
if (!QS_CONFIG.defaultParams[key]) {
|
||||||
|
nonDefaultSearchParams[key] = parsedQueryStrings[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasNonDefaultSearchParams =
|
||||||
|
Object.keys(nonDefaultSearchParams).length > 0;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
result: { hosts, count, actions, relatedSearchableKeys, searchableKeys },
|
result: { hosts, count, actions, relatedSearchableKeys, searchableKeys },
|
||||||
@@ -99,6 +116,14 @@ function HostList({ i18n }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSmartInventoryClick = () => {
|
||||||
|
history.push(
|
||||||
|
`/inventories/smart_inventory/add?host_filter=${encodeURIComponent(
|
||||||
|
encodeQueryString(nonDefaultSearchParams)
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const canAdd =
|
const canAdd =
|
||||||
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
||||||
|
|
||||||
@@ -157,6 +182,14 @@ function HostList({ i18n }) {
|
|||||||
itemsToDelete={selected}
|
itemsToDelete={selected}
|
||||||
pluralizedItemName={i18n._(t`Hosts`)}
|
pluralizedItemName={i18n._(t`Hosts`)}
|
||||||
/>,
|
/>,
|
||||||
|
...(canAdd
|
||||||
|
? [
|
||||||
|
<SmartInventoryButton
|
||||||
|
isDisabled={!hasNonDefaultSearchParams}
|
||||||
|
onClick={() => handleSmartInventoryClick()}
|
||||||
|
/>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
import { HostsAPI } from '../../../api';
|
import { HostsAPI } from '../../../api';
|
||||||
import {
|
import {
|
||||||
mountWithContexts,
|
mountWithContexts,
|
||||||
@@ -257,7 +258,7 @@ describe('<HostList />', () => {
|
|||||||
expect(modal.prop('title')).toEqual('Error!');
|
expect(modal.prop('title')).toEqual('Error!');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show Add button according to permissions', async () => {
|
test('should show Add and Smart Inventory buttons according to permissions', async () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<HostList />);
|
wrapper = mountWithContexts(<HostList />);
|
||||||
@@ -265,9 +266,10 @@ describe('<HostList />', () => {
|
|||||||
await waitForLoaded(wrapper);
|
await waitForLoaded(wrapper);
|
||||||
|
|
||||||
expect(wrapper.find('ToolbarAddButton').length).toBe(1);
|
expect(wrapper.find('ToolbarAddButton').length).toBe(1);
|
||||||
|
expect(wrapper.find('Button[aria-label="Smart Inventory"]').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should hide Add button according to permissions', async () => {
|
test('should hide Add and Smart Inventory buttons according to permissions', async () => {
|
||||||
HostsAPI.readOptions.mockResolvedValue({
|
HostsAPI.readOptions.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
actions: {
|
actions: {
|
||||||
@@ -282,5 +284,44 @@ describe('<HostList />', () => {
|
|||||||
await waitForLoaded(wrapper);
|
await waitForLoaded(wrapper);
|
||||||
|
|
||||||
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
|
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
|
||||||
|
expect(wrapper.find('Button[aria-label="Smart Inventory"]').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Smart Inventory button should be disabled when no search params are present', async () => {
|
||||||
|
let wrapper;
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<HostList />);
|
||||||
|
});
|
||||||
|
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({
|
||||||
|
initialEntries: ['/hosts?host.name__icontains=foo'],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<HostList />, {
|
||||||
|
context: { router: { history } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForLoaded(wrapper);
|
||||||
|
expect(
|
||||||
|
wrapper.find('Button[aria-label="Smart Inventory"]').props().isDisabled
|
||||||
|
).toBe(false);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Button[aria-label="Smart Inventory"]').simulate('click');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(history.location.pathname).toEqual(
|
||||||
|
'/inventories/smart_inventory/add'
|
||||||
|
);
|
||||||
|
expect(history.location.search).toEqual(
|
||||||
|
'?host_filter=name__icontains%3Dfoo'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { func } from 'prop-types';
|
||||||
|
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { useKebabifiedMenu } from '../../../contexts/Kebabified';
|
||||||
|
|
||||||
|
function SmartInventoryButton({ onClick, i18n, isDisabled }) {
|
||||||
|
const { isKebabified } = useKebabifiedMenu();
|
||||||
|
|
||||||
|
if (isKebabified) {
|
||||||
|
return (
|
||||||
|
<DropdownItem
|
||||||
|
key="add"
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
component="button"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{i18n._(t`Smart Inventory`)}
|
||||||
|
</DropdownItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key="smartInventory"
|
||||||
|
content={
|
||||||
|
!isDisabled
|
||||||
|
? i18n._(t`Create a new Smart Inventory with the applied filter`)
|
||||||
|
: i18n._(
|
||||||
|
t`Enter at least one search filter to create a new Smart Inventory`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={i18n._(t`Smart Inventory`)}
|
||||||
|
variant="secondary"
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
>
|
||||||
|
{i18n._(t`Smart Inventory`)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
SmartInventoryButton.propTypes = {
|
||||||
|
onClick: func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withI18n()(SmartInventoryButton);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||||
|
import SmartInventoryButton from './SmartInventoryButton';
|
||||||
|
|
||||||
|
describe('<SmartInventoryButton />', () => {
|
||||||
|
test('should render button', () => {
|
||||||
|
const onClick = jest.fn();
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<SmartInventoryButton onClick={onClick} />
|
||||||
|
);
|
||||||
|
const button = wrapper.find('button');
|
||||||
|
expect(button).toHaveLength(1);
|
||||||
|
button.simulate('click');
|
||||||
|
expect(onClick).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useCallback } from 'react';
|
|||||||
import { Formik, useField, useFormikContext } from 'formik';
|
import { Formik, useField, useFormikContext } from 'formik';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { func, shape, arrayOf } from 'prop-types';
|
import { func, shape, arrayOf } from 'prop-types';
|
||||||
import { Form } from '@patternfly/react-core';
|
import { Form } from '@patternfly/react-core';
|
||||||
import { InstanceGroup } from '../../../types';
|
import { InstanceGroup } from '../../../types';
|
||||||
@@ -14,6 +15,10 @@ import {
|
|||||||
FormColumnLayout,
|
FormColumnLayout,
|
||||||
FormFullWidthLayout,
|
FormFullWidthLayout,
|
||||||
} from '../../../components/FormLayout';
|
} from '../../../components/FormLayout';
|
||||||
|
import {
|
||||||
|
toHostFilter,
|
||||||
|
toSearchParams,
|
||||||
|
} from '../../../components/Lookup/shared/HostFilterUtils';
|
||||||
import HostFilterLookup from '../../../components/Lookup/HostFilterLookup';
|
import HostFilterLookup from '../../../components/Lookup/HostFilterLookup';
|
||||||
import InstanceGroupsLookup from '../../../components/Lookup/InstanceGroupsLookup';
|
import InstanceGroupsLookup from '../../../components/Lookup/InstanceGroupsLookup';
|
||||||
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
|
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
|
||||||
@@ -109,9 +114,17 @@ function SmartInventoryForm({
|
|||||||
onCancel,
|
onCancel,
|
||||||
submitError,
|
submitError,
|
||||||
}) {
|
}) {
|
||||||
|
const { search } = useLocation();
|
||||||
|
const queryParams = new URLSearchParams(search);
|
||||||
|
const hostFilterFromParams = queryParams.get('host_filter');
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
description: inventory.description || '',
|
description: inventory.description || '',
|
||||||
host_filter: inventory.host_filter || '',
|
host_filter:
|
||||||
|
inventory.host_filter ||
|
||||||
|
(hostFilterFromParams
|
||||||
|
? toHostFilter(toSearchParams(hostFilterFromParams))
|
||||||
|
: ''),
|
||||||
instance_groups: instanceGroups || [],
|
instance_groups: instanceGroups || [],
|
||||||
kind: 'smart',
|
kind: 'smart',
|
||||||
name: inventory.name || '',
|
name: inventory.name || '',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
import {
|
import {
|
||||||
mountWithContexts,
|
mountWithContexts,
|
||||||
waitForElement,
|
waitForElement,
|
||||||
@@ -135,6 +136,29 @@ describe('<SmartInventoryForm />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should pre-fill the host filter when query param present and not editing', async () => {
|
||||||
|
let wrapper;
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: [
|
||||||
|
'/inventories/smart_inventory/add?host_filter=name__icontains%3Dfoo',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SmartInventoryForm onCancel={() => {}} onSubmit={() => {}} />,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
const nameChipGroup = wrapper.find(
|
||||||
|
'HostFilterLookup ChipGroup[categoryName="Name"]'
|
||||||
|
);
|
||||||
|
expect(nameChipGroup.find('Chip').length).toBe(1);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
test('should throw content error when option request fails', async () => {
|
test('should throw content error when option request fails', async () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
InventoriesAPI.readOptions.mockImplementationOnce(() =>
|
InventoriesAPI.readOptions.mockImplementationOnce(() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user