diff --git a/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx b/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx index d1b15a8a06..78a5f3fd6d 100644 --- a/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx +++ b/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx @@ -12,6 +12,7 @@ import { FormGroup, InputGroup, Modal, + Tooltip, } from '@patternfly/react-core'; import ChipGroup from '../ChipGroup'; import Popover from '../Popover'; @@ -243,6 +244,36 @@ function HostFilterLookup({ }); }; + const renderLookup = () => ( + + + + {searchColumns.map(({ name, key }) => ( + + {chips[key]?.chips?.map(chip => ( + + {chip.node} + + ))} + + ))} + + + ); + return ( } > - - - - {searchColumns.map(({ name, key }) => ( - - {chips[key]?.chips?.map(chip => ( - - {chip.node} - - ))} - - ))} - - + {renderLookup()} + + ) : ( + renderLookup() + )} { + if (!QS_CONFIG.defaultParams[key]) { + nonDefaultSearchParams[key] = parsedQueryStrings[key]; + } + }); + + const hasNonDefaultSearchParams = + Object.keys(nonDefaultSearchParams).length > 0; const { 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 = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); @@ -157,6 +182,14 @@ function HostList({ i18n }) { itemsToDelete={selected} pluralizedItemName={i18n._(t`Hosts`)} />, + ...(canAdd + ? [ + handleSmartInventoryClick()} + />, + ] + : []), ]} /> )} diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx index b65c98b67b..5b502979e1 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; import { HostsAPI } from '../../../api'; import { mountWithContexts, @@ -257,7 +258,7 @@ describe('', () => { 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; await act(async () => { wrapper = mountWithContexts(); @@ -265,9 +266,10 @@ describe('', () => { await waitForLoaded(wrapper); 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({ data: { actions: { @@ -282,5 +284,44 @@ describe('', () => { await waitForLoaded(wrapper); 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(); + }); + 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(, { + 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' + ); }); }); diff --git a/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.jsx b/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.jsx new file mode 100644 index 0000000000..9e90bbf531 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.jsx @@ -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 ( + + {i18n._(t`Smart Inventory`)} + + ); + } + + return ( + +
+ +
+
+ ); +} +SmartInventoryButton.propTypes = { + onClick: func.isRequired, +}; + +export default withI18n()(SmartInventoryButton); diff --git a/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.test.jsx b/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.test.jsx new file mode 100644 index 0000000000..b8f47725f5 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import SmartInventoryButton from './SmartInventoryButton'; + +describe('', () => { + test('should render button', () => { + const onClick = jest.fn(); + const wrapper = mountWithContexts( + + ); + const button = wrapper.find('button'); + expect(button).toHaveLength(1); + button.simulate('click'); + expect(onClick).toHaveBeenCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx index 9b82b25a8b..a851fbfdc4 100644 --- a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useCallback } from 'react'; import { Formik, useField, useFormikContext } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { useLocation } from 'react-router-dom'; import { func, shape, arrayOf } from 'prop-types'; import { Form } from '@patternfly/react-core'; import { InstanceGroup } from '../../../types'; @@ -14,6 +15,10 @@ import { FormColumnLayout, FormFullWidthLayout, } from '../../../components/FormLayout'; +import { + toHostFilter, + toSearchParams, +} from '../../../components/Lookup/shared/HostFilterUtils'; import HostFilterLookup from '../../../components/Lookup/HostFilterLookup'; import InstanceGroupsLookup from '../../../components/Lookup/InstanceGroupsLookup'; import OrganizationLookup from '../../../components/Lookup/OrganizationLookup'; @@ -109,9 +114,17 @@ function SmartInventoryForm({ onCancel, submitError, }) { + const { search } = useLocation(); + const queryParams = new URLSearchParams(search); + const hostFilterFromParams = queryParams.get('host_filter'); + const initialValues = { description: inventory.description || '', - host_filter: inventory.host_filter || '', + host_filter: + inventory.host_filter || + (hostFilterFromParams + ? toHostFilter(toSearchParams(hostFilterFromParams)) + : ''), instance_groups: instanceGroups || [], kind: 'smart', name: inventory.name || '', diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx index 1f3c1127cc..382a4cea80 100644 --- a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; import { mountWithContexts, waitForElement, @@ -135,6 +136,29 @@ describe('', () => { }); }); + 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( + {}} 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 () => { let wrapper; InventoriesAPI.readOptions.mockImplementationOnce(() =>