From 87604749b73f76d5700ea87d8a04121994ac1380 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 14 Dec 2020 12:01:58 -0500 Subject: [PATCH 1/4] Adds smart inventory button on host list --- .../src/screens/Host/HostList/HostList.jsx | 58 ++++++++++++++++++- .../screens/Host/HostList/HostList.test.jsx | 45 +++++++++++++- .../Inventory/shared/SmartInventoryForm.jsx | 17 +++++- .../shared/SmartInventoryForm.test.jsx | 24 ++++++++ 4 files changed, 137 insertions(+), 7 deletions(-) diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.jsx index 1f5e0833bf..d62bae0d32 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.jsx @@ -1,8 +1,8 @@ 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 { t } from '@lingui/macro'; -import { Card, PageSection } from '@patternfly/react-core'; +import { Button, Card, PageSection, Tooltip } from '@patternfly/react-core'; import { HostsAPI } from '../../../api'; import AlertModal from '../../../components/AlertModal'; @@ -13,7 +13,11 @@ import PaginatedDataList, { ToolbarDeleteButton, } from '../../../components/PaginatedDataList'; import useRequest, { useDeleteItems } from '../../../util/useRequest'; -import { getQSConfig, parseQueryString } from '../../../util/qs'; +import { + encodeQueryString, + getQSConfig, + parseQueryString, +} from '../../../util/qs'; import HostListItem from './HostListItem'; @@ -24,9 +28,21 @@ const QS_CONFIG = getQSConfig('host', { }); function HostList({ i18n }) { + const history = useHistory(); const location = useLocation(); const match = useRouteMatch(); 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 { result: { hosts, count, actions, relatedSearchableKeys, searchableKeys }, @@ -99,6 +115,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 +181,34 @@ function HostList({ i18n }) { itemsToDelete={selected} pluralizedItemName={i18n._(t`Hosts`)} />, + ...(canAdd + ? [ + +
+ +
+
, + ] + : []), ]} /> )} 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/Inventory/shared/SmartInventoryForm.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx index 9b82b25a8b..4d5805db64 100644 --- a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx @@ -2,7 +2,8 @@ import React, { useEffect, useCallback } from 'react'; import { Formik, useField, useFormikContext } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { func, shape, arrayOf } from 'prop-types'; +import { useLocation } from 'react-router-dom'; +import { func, shape, object, arrayOf } from 'prop-types'; import { Form } from '@patternfly/react-core'; import { InstanceGroup } from '../../../types'; import { VariablesField } from '../../../components/CodeMirrorInput'; @@ -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(() => From fa6de04e790638444be2f85811f23476b4a3aaf3 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 13 Jan 2021 14:00:17 -0500 Subject: [PATCH 2/4] Remove object import as its not used --- awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx index 4d5805db64..a851fbfdc4 100644 --- a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx @@ -3,7 +3,7 @@ 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, object, arrayOf } from 'prop-types'; +import { func, shape, arrayOf } from 'prop-types'; import { Form } from '@patternfly/react-core'; import { InstanceGroup } from '../../../types'; import { VariablesField } from '../../../components/CodeMirrorInput'; From bb2248cb2409dfdf56d5efdab89fde1ca2a4e1f8 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 14 Jan 2021 13:30:02 -0500 Subject: [PATCH 3/4] Adds tooltip around host filter lookup when an organization is not selected --- .../components/Lookup/HostFilterLookup.jsx | 67 ++++++++++++------- 1 file changed, 41 insertions(+), 26 deletions(-) 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() + )} Date: Fri, 15 Jan 2021 14:50:41 -0500 Subject: [PATCH 4/4] Move smart inventory button out to it's own component to properly handle kebabification --- .../src/screens/Host/HostList/HostList.jsx | 31 +++-------- .../Host/HostList/SmartInventoryButton.jsx | 53 +++++++++++++++++++ .../HostList/SmartInventoryButton.test.jsx | 16 ++++++ 3 files changed, 75 insertions(+), 25 deletions(-) create mode 100644 awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.jsx create mode 100644 awx/ui_next/src/screens/Host/HostList/SmartInventoryButton.test.jsx diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.jsx index d62bae0d32..0fabce1b60 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.jsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useHistory, useLocation, useRouteMatch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Button, Card, PageSection, Tooltip } from '@patternfly/react-core'; +import { Card, PageSection } from '@patternfly/react-core'; import { HostsAPI } from '../../../api'; import AlertModal from '../../../components/AlertModal'; @@ -20,6 +20,7 @@ import { } from '../../../util/qs'; import HostListItem from './HostListItem'; +import SmartInventoryButton from './SmartInventoryButton'; const QS_CONFIG = getQSConfig('host', { page: 1, @@ -183,30 +184,10 @@ function HostList({ i18n }) { />, ...(canAdd ? [ - -
- -
-
, + handleSmartInventoryClick()} + />, ] : []), ]} 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(); + }); +});