diff --git a/awx/ui_next/src/api/models/Groups.js b/awx/ui_next/src/api/models/Groups.js index 0e76edca8c..747949eb85 100644 --- a/awx/ui_next/src/api/models/Groups.js +++ b/awx/ui_next/src/api/models/Groups.js @@ -35,7 +35,7 @@ class Groups extends Base { } readChildren(id, params) { - return this.http.get(`${this.baseUrl}${id}/children/`, params); + return this.http.get(`${this.baseUrl}${id}/children/`, { params }); } } diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx index 927ad09f78..48fc566e2c 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx @@ -1,26 +1,27 @@ -import React, { useCallback } from 'react'; -import { useHistory } from 'react-router-dom'; +import React, { useCallback, useEffect, useState, useContext } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import PropTypes from 'prop-types'; +import { Button, DropdownItem } from '@patternfly/react-core'; import useRequest, { useDismissableError } from '../../util/useRequest'; -import { InventoriesAPI } from '../../api'; +import { InventoriesAPI, CredentialTypesAPI } from '../../api'; import AlertModal from '../AlertModal'; import ErrorDetail from '../ErrorDetail'; import AdHocCommandsWizard from './AdHocCommandsWizard'; +import { KebabifiedContext } from '../../contexts/Kebabified'; import ContentLoading from '../ContentLoading'; +import ContentError from '../ContentError'; -function AdHocCommands({ - onClose, - adHocItems, - itemId, - i18n, - moduleOptions, - credentialTypeId, -}) { +function AdHocCommands({ adHocItems, i18n, hasListItems }) { const history = useHistory(); + const { id } = useParams(); + + const [isWizardOpen, setIsWizardOpen] = useState(false); + const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext); + const verbosityOptions = [ { value: '0', key: '0', label: i18n._(t`0 (Normal)`) }, { value: '1', key: '1', label: i18n._(t`1 (Verbose)`) }, @@ -28,26 +29,51 @@ function AdHocCommands({ { value: '3', key: '3', label: i18n._(t`3 (Debug)`) }, { value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) }, ]; + useEffect(() => { + if (isKebabified) { + onKebabModalChange(isWizardOpen); + } + }, [isKebabified, isWizardOpen, onKebabModalChange]); + const { + result: { moduleOptions, credentialTypeId, isAdHocDisabled }, + request: fetchData, + error: fetchError, + } = useRequest( + useCallback(async () => { + const [options, cred] = await Promise.all([ + InventoriesAPI.readAdHocOptions(id), + CredentialTypesAPI.read({ namespace: 'ssh' }), + ]); + return { + moduleOptions: options.data.actions.GET.module_name.choices, + credentialTypeId: cred.data.results[0].id, + isAdHocDisabled: !options.data.actions.POST, + }; + }, [id]), + { moduleOptions: [], isAdHocDisabled: true } + ); + useEffect(() => { + fetchData(); + }, [fetchData]); const { isloading: isLaunchLoading, - error, + error: launchError, request: launchAdHocCommands, } = useRequest( useCallback( async values => { - const { data } = await InventoriesAPI.launchAdHocCommands( - itemId, - values - ); + const { data } = await InventoriesAPI.launchAdHocCommands(id, values); history.push(`/jobs/command/${data.id}/output`); }, - [itemId, history] + [id, history] ) ); - const { dismissError } = useDismissableError(error); + const { error, dismissError } = useDismissableError( + launchError || fetchError + ); const handleSubmit = async values => { const { credential, ...remainingValues } = values; @@ -64,7 +90,7 @@ function AdHocCommands({ return ; } - if (error) { + if (error && isWizardOpen) { return ( { dismissError(); + setIsWizardOpen(false); }} > - <> - {i18n._(t`Failed to launch job.`)} - - + {launchError ? ( + <> + {i18n._(t`Failed to launch job.`)} + + + ) : ( + + )} ); } return ( - dismissError()} - /> + // render buttons for drop down and for toolbar + // if modal is open render the modal + <> + {isKebabified ? ( + setIsWizardOpen(true)} + > + {i18n._(t`Run Command`)} + + ) : ( + + )} + + {isWizardOpen && ( + setIsWizardOpen(false)} + onLaunch={handleSubmit} + onDismissError={() => dismissError()} + /> + )} + ); } AdHocCommands.propTypes = { adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired, - itemId: PropTypes.number.isRequired, + hasListItems: PropTypes.bool.isRequired, }; export default withI18n()(AdHocCommands); diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx index f83009b46a..2cbe7250f0 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.test.jsx @@ -10,7 +10,12 @@ import AdHocCommands from './AdHocCommands'; jest.mock('../../api/models/CredentialTypes'); jest.mock('../../api/models/Inventories'); jest.mock('../../api/models/Credentials'); - +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + }), +})); const credentials = [ { id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' }, { id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' }, @@ -18,10 +23,7 @@ const credentials = [ { id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' }, { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, ]; -const moduleOptions = [ - ['command', 'command'], - ['shell', 'shell'], -]; + const adHocItems = [ { name: 'Inventory 1 Org 0', @@ -30,6 +32,26 @@ const adHocItems = [ ]; describe('', () => { + beforeEach(() => { + InventoriesAPI.readAdHocOptions.mockResolvedValue({ + data: { + actions: { + GET: { + module_name: { + choices: [ + ['command', 'command'], + ['shell', 'shell'], + ], + }, + }, + POST: {}, + }, + }, + }); + CredentialTypesAPI.read.mockResolvedValue({ + data: { count: 1, results: [{ id: 1, name: 'cred' }] }, + }); + }); let wrapper; afterEach(() => { wrapper.unmount(); @@ -39,19 +61,45 @@ describe('', () => { test('mounts successfully', async () => { await act(async () => { wrapper = mountWithContexts( - {}} - itemId={1} - credentialTypeId={1} - adHocItems={adHocItems} - moduleOptions={moduleOptions} - /> + ); }); expect(wrapper.find('AdHocCommands').length).toBe(1); }); + test('should open the wizard', async () => { + InventoriesAPI.readAdHocOptions.mockResolvedValue({ + data: { + actions: { + GET: { + module_name: { + choices: [ + ['command', 'command'], + ['foo', 'foo'], + ], + }, + verbosity: { choices: [[1], [2]] }, + }, + }, + }, + }); + CredentialTypesAPI.read.mockResolvedValue({ + data: { results: [{ id: 1 }] }, + }); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await act(async () => + wrapper.find('button[aria-label="Run Command"]').prop('onClick')() + ); + + wrapper.update(); + + expect(wrapper.find('AdHocCommandsWizard').length).toBe(1); + }); + test('should submit properly', async () => { InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } }); CredentialsAPI.read.mockResolvedValue({ @@ -62,17 +110,13 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - {}} - itemId={1} - credentialTypeId={1} - adHocItems={adHocItems} - moduleOptions={moduleOptions} - /> + ); }); + await act(async () => + wrapper.find('button[aria-label="Run Command"]').prop('onClick')() + ); wrapper.update(); expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); @@ -174,17 +218,13 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - {}} - credentialTypeId={1} - itemId={1} - adHocItems={adHocItems} - moduleOptions={moduleOptions} - /> + ); }); + await act(async () => + wrapper.find('button[aria-label="Run Command"]').prop('onClick')() + ); wrapper.update(); expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); @@ -237,4 +277,69 @@ describe('', () => { await waitForElement(wrapper, 'ErrorDetail', el => el.length > 0); }); + + test('should disable run command button due to permissions', async () => { + InventoriesAPI.readHosts.mockResolvedValue({ + data: { results: [], count: 0 }, + }); + InventoriesAPI.readAdHocOptions.mockResolvedValue({ + data: { + actions: { + GET: { module_name: { choices: [['module']] } }, + }, + }, + }); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + const runCommandsButton = wrapper.find('button[aria-label="Run Command"]'); + expect(runCommandsButton.prop('disabled')).toBe(true); + }); + + test('should disable run command button due to lack of list items', async () => { + InventoriesAPI.readHosts.mockResolvedValue({ + data: { results: [], count: 0 }, + }); + InventoriesAPI.readAdHocOptions.mockResolvedValue({ + data: { + actions: { + GET: { module_name: { choices: [['module']] } }, + }, + }, + }); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + const runCommandsButton = wrapper.find('button[aria-label="Run Command"]'); + expect(runCommandsButton.prop('disabled')).toBe(true); + }); + + test('should open alert modal when error on fetching data', async () => { + InventoriesAPI.readAdHocOptions.mockRejectedValue( + new Error({ + response: { + config: { + method: 'options', + url: '/api/v2/inventories/1/', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await act(async () => wrapper.find('button').prop('onClick')()); + wrapper.update(); + expect(wrapper.find('ErrorDetail').length).toBe(1); + }); }); diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx index 45d7426d30..865564ba92 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommandsWizard.jsx @@ -134,8 +134,7 @@ const FormikApp = withFormik({ FormikApp.propTypes = { onLaunch: PropTypes.func.isRequired, - moduleOptions: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)) - .isRequired, + moduleOptions: PropTypes.arrayOf(PropTypes.array).isRequired, verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired, onCloseWizard: PropTypes.func.isRequired, credentialTypeId: PropTypes.number.isRequired, diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.jsx index e2f8898c98..46a3617d7e 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.jsx @@ -59,7 +59,7 @@ function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) { argumentsHelpers.setTouched(true)} - placeholder={i18n._(t`Enter arguments`)} isRequired={ moduleNameField.value === 'command' || moduleNameField.value === 'shell' @@ -137,7 +136,7 @@ function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) { /> { + if (isKebabified) { + onKebabModalChange(isOpen); + } + }, [isKebabified, isOpen, onKebabModalChange]); + function cannotDisassociate(item) { return !item.summary_fields?.user_capabilities?.delete; } @@ -67,18 +76,30 @@ function DisassociateButton({ // See: https://github.com/patternfly/patternfly-react/issues/1894 return ( <> - -
- -
-
+ {isKebabified ? ( + setIsOpen(true)} + > + {i18n._(t`Disassociate`)} + + ) : ( + +
+ +
+
+ )} {isOpen && ( , + + + , ]} diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.jsx deleted file mode 100644 index e5393aa50d..0000000000 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.jsx +++ /dev/null @@ -1,58 +0,0 @@ -import React, { useState } from 'react'; -import { func } from 'prop-types'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { - Dropdown, - DropdownItem, - DropdownPosition, - DropdownToggle, -} from '@patternfly/react-core'; - -function AddHostDropdown({ i18n, onAddNew, onAddExisting }) { - const [isOpen, setIsOpen] = useState(false); - - const dropdownItems = [ - - {i18n._(t`Add New Host`)} - , - - {i18n._(t`Add Existing Host`)} - , - ]; - - return ( - setIsOpen(prevState => !prevState)} - > - {i18n._(t`Add`)} - - } - dropdownItems={dropdownItems} - /> - ); -} - -AddHostDropdown.propTypes = { - onAddNew: func.isRequired, - onAddExisting: func.isRequired, -}; - -export default withI18n()(AddHostDropdown); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx index 22ee9b9338..df1ebe2177 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx @@ -2,14 +2,8 @@ import React, { useEffect, useCallback, useState } from 'react'; import { useHistory, useLocation, useParams } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { - Button, - Tooltip, - DropdownItem, - ToolbarItem, -} from '@patternfly/react-core'; import { getQSConfig, mergeParams, parseQueryString } from '../../../util/qs'; -import { GroupsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api'; +import { GroupsAPI, InventoriesAPI } from '../../../api'; import useRequest, { useDeleteItems, @@ -22,10 +16,9 @@ import ErrorDetail from '../../../components/ErrorDetail'; import PaginatedDataList from '../../../components/PaginatedDataList'; import AssociateModal from '../../../components/AssociateModal'; import DisassociateButton from '../../../components/DisassociateButton'; -import { Kebabified } from '../../../contexts/Kebabified'; import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands'; import InventoryGroupHostListItem from './InventoryGroupHostListItem'; -import AddHostDropdown from './AddHostDropdown'; +import AddDropdown from '../shared/AddDropdown'; const QS_CONFIG = getQSConfig('host', { page: 1, @@ -35,7 +28,6 @@ const QS_CONFIG = getQSConfig('host', { function InventoryGroupHostList({ i18n }) { const [isModalOpen, setIsModalOpen] = useState(false); - const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false); const { id: inventoryId, groupId } = useParams(); const location = useLocation(); const history = useHistory(); @@ -47,9 +39,6 @@ function InventoryGroupHostList({ i18n }) { actions, relatedSearchableKeys, searchableKeys, - moduleOptions, - credentialTypeId, - isAdHocDisabled, }, error: contentError, isLoading, @@ -57,16 +46,9 @@ function InventoryGroupHostList({ i18n }) { } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const [ - response, - actionsResponse, - adHocOptions, - cred, - ] = await Promise.all([ + const [response, actionsResponse] = await Promise.all([ GroupsAPI.readAllHosts(groupId, params), InventoriesAPI.readHostsOptions(inventoryId), - InventoriesAPI.readAdHocOptions(inventoryId), - CredentialTypesAPI.read({ namespace: 'ssh' }), ]); return { @@ -79,9 +61,6 @@ function InventoryGroupHostList({ i18n }) { searchableKeys: Object.keys( actionsResponse.data.actions?.GET || {} ).filter(key => actionsResponse.data.actions?.GET[key].filterable), - moduleOptions: adHocOptions.data.actions.GET.module_name.choices, - credentialTypeId: cred.data.results[0].id, - isAdHocDisabled: !adHocOptions.data.actions.POST, }; }, [groupId, inventoryId, location.search]), { @@ -90,8 +69,6 @@ function InventoryGroupHostList({ i18n }) { actions: {}, relatedSearchableKeys: [], searchableKeys: [], - moduleOptions: [], - isAdHocDisabled: true, } ); @@ -166,7 +143,26 @@ function InventoryGroupHostList({ i18n }) { const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); const addFormUrl = `/inventories/inventory/${inventoryId}/groups/${groupId}/nested_hosts/add`; + const addButtonOptions = []; + if (canAdd) { + addButtonOptions.push( + { + onAdd: () => setIsModalOpen(true), + title: i18n._(t`Add existing host`), + label: i18n._(t`host`), + key: 'existing', + }, + { + onAdd: () => history.push(addFormUrl), + title: i18n._(t`Add new host`), + label: i18n._(t`host`), + key: 'new', + } + ); + } + + const addButton = ; return ( <> setIsModalOpen(true)} - onAddNew={() => history.push(addFormUrl)} - />, - ] - : []), - - {({ isKebabified }) => - isKebabified ? ( - setIsAdHocCommandsOpen(true)} - isDisabled={hostCount === 0 || isAdHocDisabled} - > - {i18n._(t`Run command`)} - - ) : ( - - - - - - ) - } - , + ...(canAdd ? [addButton] : []), + 0} + />, handleSelect(o)} /> )} - emptyStateControls={ - canAdd && ( - setIsModalOpen(true)} - onAddNew={() => history.push(addFormUrl)} - /> - ) - } + emptyStateControls={canAdd && addButton} /> {isModalOpen && ( )} - {isAdHocCommandsOpen && ( - setIsAdHocCommandsOpen(false)} - credentialTypeId={credentialTypeId} - moduleOptions={moduleOptions} - /> - )} {associateError && ( ', () => { }, }, }); - InventoriesAPI.readAdHocOptions.mockResolvedValue({ - data: { - actions: { - GET: { module_name: { choices: [['module']] } }, - POST: {}, - }, - }, - }); - CredentialTypesAPI.read.mockResolvedValue({ - data: { count: 1, results: [{ id: 1, name: 'cred' }] }, - }); await act(async () => { wrapper = mountWithContexts(); }); @@ -107,31 +96,8 @@ describe('', () => { }); }); - test('should render enabled ad hoc commands button', async () => { - GroupsAPI.readAllHosts.mockResolvedValue({ - data: { ...mockHosts }, - }); - InventoriesAPI.readHostsOptions.mockResolvedValue({ - data: { - actions: { - GET: {}, - POST: {}, - }, - }, - }); - await act(async () => { - wrapper = mountWithContexts(); - }); - - await waitForElement( - wrapper, - 'button[aria-label="Run command"]', - el => el.prop('disabled') === false - ); - }); - test('should show add dropdown button according to permissions', async () => { - expect(wrapper.find('AddHostDropdown').length).toBe(1); + expect(wrapper.find('AddDropdown').length).toBe(1); InventoriesAPI.readHostsOptions.mockResolvedValueOnce({ data: { actions: { @@ -143,7 +109,7 @@ describe('', () => { wrapper = mountWithContexts(); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); - expect(wrapper.find('AddHostDropdown').length).toBe(0); + expect(wrapper.find('AddDropdown').length).toBe(0); }); test('expected api calls are made for multi-delete', async () => { @@ -190,11 +156,11 @@ describe('', () => { test('should show associate host modal when adding an existing host', () => { const dropdownToggle = wrapper.find( - 'DropdownToggle button[aria-label="add host"]' + 'DropdownToggle button[aria-label="add"]' ); dropdownToggle.simulate('click'); wrapper - .find('DropdownItem[aria-label="add existing host"]') + .find('DropdownItem[aria-label="Add existing host"]') .simulate('click'); expect(wrapper.find('AssociateModal').length).toBe(1); wrapper.find('ModalBoxCloseButton').simulate('click'); @@ -209,12 +175,10 @@ describe('', () => { results: [{ id: 123, name: 'foo', url: '/api/v2/hosts/123/' }], }, }); - wrapper - .find('DropdownToggle button[aria-label="add host"]') - .simulate('click'); + wrapper.find('DropdownToggle button[aria-label="add"]').simulate('click'); await act(async () => { wrapper - .find('DropdownItem[aria-label="add existing host"]') + .find('DropdownItem[aria-label="Add existing host"]') .simulate('click'); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); @@ -241,12 +205,10 @@ describe('', () => { results: [{ id: 123, name: 'foo', url: '/api/v2/hosts/123/' }], }, }); - wrapper - .find('DropdownToggle button[aria-label="add host"]') - .simulate('click'); + wrapper.find('DropdownToggle button[aria-label="add"]').simulate('click'); await act(async () => { wrapper - .find('DropdownItem[aria-label="add existing host"]') + .find('DropdownItem[aria-label="Add existing host"]') .simulate('click'); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); @@ -288,10 +250,10 @@ describe('', () => { }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); const dropdownToggle = wrapper.find( - 'DropdownToggle button[aria-label="add host"]' + 'DropdownToggle button[aria-label="add"]' ); dropdownToggle.simulate('click'); - wrapper.find('DropdownItem[aria-label="add new host"]').simulate('click'); + wrapper.find('DropdownItem[aria-label="Add new host"]').simulate('click'); expect(history.location.pathname).toEqual( '/inventories/inventory/1/groups/2/nested_hosts/add' ); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx index 66277ab537..8c8676faf1 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx @@ -2,17 +2,11 @@ import React, { useCallback, useState, useEffect } from 'react'; import { useParams, useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { - Button, - Tooltip, - DropdownItem, - ToolbarGroup, - ToolbarItem, -} from '@patternfly/react-core'; +import { Button, Tooltip } from '@patternfly/react-core'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import useSelected from '../../../util/useSelected'; import useRequest from '../../../util/useRequest'; -import { InventoriesAPI, GroupsAPI, CredentialTypesAPI } from '../../../api'; +import { InventoriesAPI, GroupsAPI } from '../../../api'; import AlertModal from '../../../components/AlertModal'; import ErrorDetail from '../../../components/ErrorDetail'; import DataListToolbar from '../../../components/DataListToolbar'; @@ -24,7 +18,6 @@ import InventoryGroupItem from './InventoryGroupItem'; import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal'; import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands'; -import { Kebabified } from '../../../contexts/Kebabified'; const QS_CONFIG = getQSConfig('group', { page: 1, @@ -52,7 +45,7 @@ const useModal = () => { function InventoryGroupsList({ i18n }) { const [deletionError, setDeletionError] = useState(null); const [isDeleteLoading, setIsDeleteLoading] = useState(false); - const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false); + const location = useLocation(); const { isModalOpen, toggleModal } = useModal(); const { id: inventoryId } = useParams(); @@ -64,9 +57,6 @@ function InventoryGroupsList({ i18n }) { actions, relatedSearchableKeys, searchableKeys, - moduleOptions, - credentialTypeId, - isAdHocDisabled, }, error: contentError, isLoading, @@ -74,11 +64,9 @@ function InventoryGroupsList({ i18n }) { } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const [response, groupOptions, adHocOptions, cred] = await Promise.all([ + const [response, groupOptions] = await Promise.all([ InventoriesAPI.readGroups(inventoryId, params), InventoriesAPI.readGroupsOptions(inventoryId), - InventoriesAPI.readAdHocOptions(inventoryId), - CredentialTypesAPI.read({ namespace: 'ssh' }), ]); return { @@ -91,9 +79,6 @@ function InventoryGroupsList({ i18n }) { searchableKeys: Object.keys( groupOptions.data.actions?.GET || {} ).filter(key => groupOptions.data.actions?.GET[key].filterable), - moduleOptions: adHocOptions.data.actions.GET.module_name.choices, - credentialTypeId: cred.data.results[0].id, - isAdHocDisabled: !adHocOptions.data.actions.POST, }; }, [inventoryId, location]), { @@ -161,29 +146,6 @@ function InventoryGroupsList({ i18n }) { }; const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); - const kebabedAdditionalControls = () => { - return ( - <> - setIsAdHocCommandsOpen(true)} - isDisabled={groupCount === 0 || isAdHocDisabled} - > - {i18n._(t`Run command`)} - - - - {i18n._(t`Delete`)} - - - ); - }; return ( <> @@ -253,57 +215,24 @@ function InventoryGroupsList({ i18n }) { />, ] : []), - - {({ isKebabified }) => ( - <> - {isKebabified ? ( - kebabedAdditionalControls() - ) : ( - - - - - - - - -
- -
-
-
-
- )} - - )} -
, + 0} + />, + +
+ +
+
, ]} /> )} @@ -316,16 +245,6 @@ function InventoryGroupsList({ i18n }) { ) } /> - {isAdHocCommandsOpen && ( - setIsAdHocCommandsOpen(false)} - credentialTypeId={credentialTypeId} - moduleOptions={moduleOptions} - /> - )} {deletionError && ( ', () => { }, }, }); - InventoriesAPI.readAdHocOptions.mockResolvedValue({ - data: { - actions: { - GET: { module_name: { choices: [['module']] } }, - POST: {}, - }, - }, - }); - CredentialTypesAPI.read.mockResolvedValue({ - data: { count: 1, results: [{ id: 1, name: 'cred' }] }, - }); const history = createMemoryHistory({ initialEntries: ['/inventories/inventory/3/groups'], }); @@ -158,13 +147,6 @@ describe('', () => { expect(el.props().checked).toBe(false); }); }); - test('should render enabled ad hoc commands button', async () => { - await waitForElement( - wrapper, - 'button[aria-label="Run command"]', - el => el.prop('disabled') === false - ); - }); }); describe(' error handling', () => { let wrapper; @@ -194,16 +176,6 @@ describe(' error handling', () => { }, }) ); - InventoriesAPI.readAdHocOptions.mockResolvedValue({ - data: { - actions: { - GET: { module_name: { choices: [['module']] } }, - }, - }, - }); - CredentialTypesAPI.read.mockResolvedValue({ - data: { count: 1, results: [{ id: 1, name: 'cred' }] }, - }); }); afterEach(() => { jest.clearAllMocks(); @@ -230,21 +202,8 @@ describe(' error handling', () => { }); test('should show error modal when group is not successfully deleted from api', async () => { - const history = createMemoryHistory({ - initialEntries: ['/inventories/inventory/3/groups'], - }); - await act(async () => { - wrapper = mountWithContexts( - - - , - { - context: { - router: { history, route: { location: history.location } }, - }, - } - ); + wrapper = mountWithContexts(); }); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); @@ -281,27 +240,4 @@ describe(' error handling', () => { .invoke('onClose')(); }); }); - test('should render disabled ad hoc button', async () => { - const history = createMemoryHistory({ - initialEntries: ['/inventories/inventory/3/groups'], - }); - - await act(async () => { - wrapper = mountWithContexts( - - - , - { - context: { - router: { history, route: { location: history.location } }, - }, - } - ); - }); - - await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); - expect( - wrapper.find('button[aria-label="Run command"]').prop('disabled') - ).toBe(true); - }); }); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx index 8aa095a7a8..74d929d779 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupsList.jsx @@ -2,19 +2,13 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useParams, useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { - Button, - Tooltip, - DropdownItem, - ToolbarItem, -} from '@patternfly/react-core'; import { getQSConfig, parseQueryString, mergeParams } from '../../../util/qs'; import useRequest, { useDismissableError, useDeleteItems, } from '../../../util/useRequest'; import useSelected from '../../../util/useSelected'; -import { HostsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api'; +import { HostsAPI, InventoriesAPI } from '../../../api'; import DataListToolbar from '../../../components/DataListToolbar'; import AlertModal from '../../../components/AlertModal'; import ErrorDetail from '../../../components/ErrorDetail'; @@ -23,7 +17,6 @@ import PaginatedDataList, { } from '../../../components/PaginatedDataList'; import AssociateModal from '../../../components/AssociateModal'; import DisassociateButton from '../../../components/DisassociateButton'; -import { Kebabified } from '../../../contexts/Kebabified'; import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands'; import InventoryHostGroupItem from './InventoryHostGroupItem'; @@ -35,7 +28,6 @@ const QS_CONFIG = getQSConfig('group', { function InventoryHostGroupsList({ i18n }) { const [isModalOpen, setIsModalOpen] = useState(false); - const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false); const { hostId, id: invId } = useParams(); const { search } = useLocation(); @@ -46,9 +38,6 @@ function InventoryHostGroupsList({ i18n }) { actions, relatedSearchableKeys, searchableKeys, - moduleOptions, - isAdHocDisabled, - credentialTypeId, }, error: contentError, isLoading, @@ -62,13 +51,9 @@ function InventoryHostGroupsList({ i18n }) { data: { count, results }, }, hostGroupOptions, - adHocOptions, - cred, ] = await Promise.all([ HostsAPI.readAllGroups(hostId, params), HostsAPI.readGroupsOptions(hostId), - InventoriesAPI.readAdHocOptions(invId), - CredentialTypesAPI.read({ namespace: 'ssh' }), ]); return { @@ -81,9 +66,6 @@ function InventoryHostGroupsList({ i18n }) { searchableKeys: Object.keys( hostGroupOptions.data.actions?.GET || {} ).filter(key => hostGroupOptions.data.actions?.GET[key].filterable), - moduleOptions: adHocOptions.data.actions.GET.module_name.choices, - credentialTypeId: cred.data.results[0].id, - isAdHocDisabled: !adHocOptions.data.actions.POST, }; }, [hostId, search]), // eslint-disable-line react-hooks/exhaustive-deps { @@ -92,8 +74,6 @@ function InventoryHostGroupsList({ i18n }) { actions: {}, relatedSearchableKeys: [], searchableKeys: [], - moduleOptions: [], - isAdHocDisabled: true, } ); @@ -222,40 +202,10 @@ function InventoryHostGroupsList({ i18n }) { />, ] : []), - - {({ isKebabified }) => - isKebabified ? ( - setIsAdHocCommandsOpen(true)} - isDisabled={itemCount === 0 || isAdHocDisabled} - > - {i18n._(t`Run command`)} - - ) : ( - - - - - - ) - } - , + 0} + />, )} - {isAdHocCommandsOpen && ( - setIsAdHocCommandsOpen(false)} - credentialTypeId={credentialTypeId} - moduleOptions={moduleOptions} - /> - )} {error && ( ', () => { }, }, }); - InventoriesAPI.readAdHocOptions.mockResolvedValue({ - data: { - actions: { - GET: { module_name: { choices: [['module']] } }, - POST: {}, - }, - }, - }); - CredentialTypesAPI.read.mockResolvedValue({ - data: { count: 1, results: [{ id: 1, name: 'cred' }] }, - }); const history = createMemoryHistory({ initialEntries: ['/inventories/inventory/1/hosts/3/groups'], }); @@ -283,11 +272,4 @@ describe('', () => { wrapper.update(); expect(wrapper.find('AlertModal ErrorDetail').length).toBe(1); }); - test('should render enabled ad hoc commands button', async () => { - await waitForElement( - wrapper, - 'button[aria-label="Run command"]', - el => el.prop('disabled') === false - ); - }); }); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx index 73bd3516b0..fbee9ae6d5 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx @@ -2,14 +2,8 @@ import React, { useEffect, useState, useCallback } from 'react'; import { useParams, useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { - Button, - Tooltip, - DropdownItem, - ToolbarItem, -} from '@patternfly/react-core'; import { getQSConfig, parseQueryString } from '../../../util/qs'; -import { InventoriesAPI, HostsAPI, CredentialTypesAPI } from '../../../api'; +import { InventoriesAPI, HostsAPI } from '../../../api'; import useRequest, { useDeleteItems } from '../../../util/useRequest'; import AlertModal from '../../../components/AlertModal'; import DataListToolbar from '../../../components/DataListToolbar'; @@ -18,7 +12,6 @@ import PaginatedDataList, { ToolbarAddButton, ToolbarDeleteButton, } from '../../../components/PaginatedDataList'; -import { Kebabified } from '../../../contexts/Kebabified'; import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands'; import InventoryHostItem from './InventoryHostItem'; @@ -29,7 +22,6 @@ const QS_CONFIG = getQSConfig('host', { }); function InventoryHostList({ i18n }) { - const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false); const [selected, setSelected] = useState([]); const { id } = useParams(); const { search } = useLocation(); @@ -41,9 +33,6 @@ function InventoryHostList({ i18n }) { actions, relatedSearchableKeys, searchableKeys, - moduleOptions, - credentialTypeId, - isAdHocDisabled, }, error: contentError, isLoading, @@ -51,11 +40,9 @@ function InventoryHostList({ i18n }) { } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, search); - const [response, hostOptions, adHocOptions, cred] = await Promise.all([ + const [response, hostOptions] = await Promise.all([ InventoriesAPI.readHosts(id, params), InventoriesAPI.readHostsOptions(id), - InventoriesAPI.readAdHocOptions(id), - CredentialTypesAPI.read({ namespace: 'ssh' }), ]); return { @@ -68,9 +55,6 @@ function InventoryHostList({ i18n }) { searchableKeys: Object.keys(hostOptions.data.actions?.GET || {}).filter( key => hostOptions.data.actions?.GET[key].filterable ), - moduleOptions: adHocOptions.data.actions.GET.module_name.choices, - credentialTypeId: cred.data.results[0].id, - isAdHocDisabled: !adHocOptions.data.actions.POST, }; }, [id, search]), { @@ -79,8 +63,6 @@ function InventoryHostList({ i18n }) { actions: {}, relatedSearchableKeys: [], searchableKeys: [], - moduleOptions: [], - isAdHocDisabled: true, } ); @@ -162,40 +144,10 @@ function InventoryHostList({ i18n }) { />, ] : []), - - {({ isKebabified }) => - isKebabified ? ( - setIsAdHocCommandsOpen(true)} - isDisabled={hostCount === 0 || isAdHocDisabled} - aria-label={i18n._(t`Run command`)} - > - {i18n._(t`Run command`)} - - ) : ( - - - - - - ) - } - , + 0} + />, - {isAdHocCommandsOpen && ( - setIsAdHocCommandsOpen(false)} - credentialTypeId={credentialTypeId} - moduleOptions={moduleOptions} - itemId={parseInt(id, 10)} - /> - )} {deletionError && ( ', () => { }, }, }); - InventoriesAPI.readAdHocOptions.mockResolvedValue({ - data: { - actions: { - GET: { module_name: { choices: [['module']] } }, - POST: {}, - }, - }, - }); - CredentialTypesAPI.read.mockResolvedValue({ - data: { count: 1, results: [{ id: 1, name: 'cred' }] }, - }); await act(async () => { wrapper = mountWithContexts(); }); @@ -276,13 +265,6 @@ describe('', () => { expect(wrapper.find('ToolbarAddButton').length).toBe(1); }); - test('should render enabled ad hoc commands button', async () => { - await waitForElement( - wrapper, - 'button[aria-label="Run command"]', - el => el.prop('disabled') === false - ); - }); test('should hide Add button for users without ability to POST', async () => { InventoriesAPI.readHostsOptions.mockResolvedValueOnce({ data: { diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.jsx b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.jsx new file mode 100644 index 0000000000..eb171006c4 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.jsx @@ -0,0 +1,202 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useParams, useLocation, useHistory } from 'react-router-dom'; + +import { GroupsAPI, InventoriesAPI } from '../../../api'; +import useRequest from '../../../util/useRequest'; +import { getQSConfig, parseQueryString, mergeParams } from '../../../util/qs'; +import useSelected from '../../../util/useSelected'; + +import DataListToolbar from '../../../components/DataListToolbar'; +import PaginatedDataList from '../../../components/PaginatedDataList'; +import InventoryGroupRelatedGroupListItem from './InventoryRelatedGroupListItem'; +import AddDropdown from '../shared/AddDropdown'; +import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands'; +import AssociateModal from '../../../components/AssociateModal'; +import DisassociateButton from '../../../components/DisassociateButton'; + +const QS_CONFIG = getQSConfig('group', { + page: 1, + page_size: 20, + order_by: 'name', +}); +function InventoryRelatedGroupList({ i18n }) { + const [isModalOpen, setIsModalOpen] = useState(false); + const { id: inventoryId, groupId } = useParams(); + const location = useLocation(); + const history = useHistory(); + const { + request: fetchRelated, + result: { + groups, + itemCount, + relatedSearchableKeys, + searchableKeys, + canAdd, + }, + isLoading, + error: contentError, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const [response, actions] = await Promise.all([ + GroupsAPI.readChildren(groupId, params), + InventoriesAPI.readGroupsOptions(inventoryId), + ]); + + return { + groups: response.data.results, + itemCount: response.data.count, + relatedSearchableKeys: ( + actions?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + searchableKeys: Object.keys(actions.data.actions?.GET || {}).filter( + key => actions.data.actions?.GET[key].filterable + ), + canAdd: + actions.data.actions && + Object.prototype.hasOwnProperty.call(actions.data.actions, 'POST'), + }; + }, [groupId, location.search, inventoryId]), + { groups: [], itemCount: 0, canAdd: false } + ); + useEffect(() => { + fetchRelated(); + }, [fetchRelated]); + + const fetchGroupsToAssociate = useCallback( + params => { + return InventoriesAPI.readGroups( + inventoryId, + mergeParams(params, { not__id: inventoryId, not__parents: inventoryId }) + ); + }, + [inventoryId] + ); + + const fetchGroupsOptions = useCallback( + () => InventoriesAPI.readGroupsOptions(inventoryId), + [inventoryId] + ); + + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + groups + ); + + const addFormUrl = `/home`; + const addButtonOptions = []; + + if (canAdd) { + addButtonOptions.push( + { + onAdd: () => setIsModalOpen(true), + title: i18n._(t`Add existing group`), + label: i18n._(t`group`), + key: 'existing', + }, + { + onAdd: () => history.push(addFormUrl), + title: i18n._(t`Add new group`), + label: i18n._(t`group`), + key: 'new', + } + ); + } + + const addButton = ; + + return ( + <> + ( + + setSelected(isSelected ? [...groups] : []) + } + qsConfig={QS_CONFIG} + additionalControls={[ + ...(canAdd ? [addButton] : []), + 0} + />, + {}} + itemsToDisassociate={selected} + modalTitle={i18n._(t`Disassociate related group(s)?`)} + />, + ]} + /> + )} + renderItem={o => ( + row.id === o.id)} + onSelect={() => handleSelect(o)} + /> + )} + emptyStateControls={ + canAdd && ( + setIsModalOpen(true)} + onAddNew={() => history.push(addFormUrl)} + newTitle={i18n._(t`Add new group`)} + existingTitle={i18n._(t`Add existing group`)} + label={i18n._(t`group`)} + /> + ) + } + /> + {isModalOpen && ( + {}} + onClose={() => setIsModalOpen(false)} + title={i18n._(t`Select Groups`)} + /> + )} + + ); +} +export default withI18n()(InventoryRelatedGroupList); diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.jsx new file mode 100644 index 0000000000..834e0738ec --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.jsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { GroupsAPI, InventoriesAPI } from '../../../api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import InventoryRelatedGroupList from './InventoryRelatedGroupList'; +import mockRelatedGroups from '../shared/data.relatedGroups.json'; + +jest.mock('../../../api/models/Groups'); +jest.mock('../../../api/models/Inventories'); +jest.mock('../../../api/models/CredentialTypes'); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + }), +})); + +describe('', () => { + let wrapper; + + beforeEach(async () => { + GroupsAPI.readChildren.mockResolvedValue({ + data: { ...mockRelatedGroups }, + }); + InventoriesAPI.readGroupsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: [ + 'parents__search', + 'inventory__search', + 'inventory_sources__search', + 'created_by__search', + 'children__search', + 'modified_by__search', + 'hosts__search', + ], + }, + }); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('initially renders successfully ', () => { + expect(wrapper.find('InventoryRelatedGroupList').length).toBe(1); + }); + + test('should fetch inventory group hosts from api and render them in the list', () => { + expect(GroupsAPI.readChildren).toHaveBeenCalled(); + expect(InventoriesAPI.readGroupsOptions).toHaveBeenCalled(); + expect(wrapper.find('InventoryRelatedGroupListItem').length).toBe(3); + }); + + test('should check and uncheck the row item', async () => { + expect( + wrapper.find('DataListCheck[id="select-group-2"]').props().checked + ).toBe(false); + await act(async () => { + wrapper.find('DataListCheck[id="select-group-2"]').invoke('onChange')(); + }); + wrapper.update(); + expect( + wrapper.find('DataListCheck[id="select-group-2"]').props().checked + ).toBe(true); + await act(async () => { + wrapper.find('DataListCheck[id="select-group-2"]').invoke('onChange')(); + }); + wrapper.update(); + expect( + wrapper.find('DataListCheck[id="select-group-2"]').props().checked + ).toBe(false); + }); + + test('should check all row items when select all is checked', async () => { + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); + }); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(true); + }); + wrapper.update(); + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(true); + }); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(false); + }); + wrapper.update(); + wrapper.find('DataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); + }); + }); + + test('should show content error when api throws error on initial render', async () => { + GroupsAPI.readChildren.mockResolvedValueOnce({ + data: { ...mockRelatedGroups }, + }); + InventoriesAPI.readGroupsOptions.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); + + test('should show add dropdown button according to permissions', async () => { + GroupsAPI.readChildren.mockResolvedValueOnce({ + data: { ...mockRelatedGroups }, + }); + InventoriesAPI.readGroupsOptions.mockResolvedValueOnce({ + data: { + actions: { + GET: {}, + }, + related_search_fields: [ + 'parents__search', + 'inventory__search', + 'inventory_sources__search', + 'created_by__search', + 'children__search', + 'modified_by__search', + 'hosts__search', + ], + }, + }); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('AddDropdown').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.jsx new file mode 100644 index 0000000000..67f543c9a7 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.jsx @@ -0,0 +1,90 @@ +import 'styled-components/macro'; +import React from 'react'; +import { Link } from 'react-router-dom'; +import { string, bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; + +import { + Button, + DataListAction as _DataListAction, + DataListCheck, + DataListItem, + DataListItemCells, + DataListItemRow, + Tooltip, +} from '@patternfly/react-core'; +import { PencilAltIcon } from '@patternfly/react-icons'; +import styled from 'styled-components'; +import DataListCell from '../../../components/DataListCell'; + +import { Group } from '../../../types'; + +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 24px; + grid-template-columns: min-content 40px; +`; + +function InventoryRelatedGroupListItem({ + i18n, + detailUrl, + editUrl, + group, + isSelected, + onSelect, +}) { + const labelId = `check-action-${group.id}`; + + return ( + + + + + + {group.name} + + , + ]} + /> + + {group.summary_fields.user_capabilities?.edit && ( + + + + )} + + + + ); +} + +InventoryRelatedGroupListItem.propTypes = { + detailUrl: string.isRequired, + editUrl: string.isRequired, + group: Group.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(InventoryRelatedGroupListItem); diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.test.jsx new file mode 100644 index 0000000000..f52eaad866 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.test.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import InventoryRelatedGroupListItem from './InventoryRelatedGroupListItem'; +import mockRelatedGroups from '../shared/data.relatedGroups.json'; + +jest.mock('../../../api'); + +describe('', () => { + let wrapper; + const mockGroup = mockRelatedGroups.results[0]; + + beforeEach(() => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('should display expected row item content', () => { + expect( + wrapper + .find('DataListCell') + .first() + .text() + ).toBe(' Group 2 Inventory 0'); + }); + + test('edit button shown to users with edit capabilities', () => { + expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); + }); + + test('edit button hidden from users without edit capabilities', () => { + wrapper = mountWithContexts( + {}} + /> + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/index.js b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/index.js new file mode 100644 index 0000000000..09833dbe98 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/index.js @@ -0,0 +1 @@ +export { default } from './InventoryRelatedGroupList'; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.jsx index 4322463741..52e0640518 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.jsx @@ -1,22 +1,15 @@ -import React, { useEffect, useCallback, useState } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { - Button, - Tooltip, - DropdownItem, - ToolbarItem, -} from '@patternfly/react-core'; import DataListToolbar from '../../../components/DataListToolbar'; import PaginatedDataList from '../../../components/PaginatedDataList'; import SmartInventoryHostListItem from './SmartInventoryHostListItem'; import useRequest from '../../../util/useRequest'; import useSelected from '../../../util/useSelected'; import { getQSConfig, parseQueryString } from '../../../util/qs'; -import { InventoriesAPI, CredentialTypesAPI } from '../../../api'; +import { InventoriesAPI } from '../../../api'; import { Inventory } from '../../../types'; -import { Kebabified } from '../../../contexts/Kebabified'; import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands'; const QS_CONFIG = getQSConfig('host', { @@ -27,35 +20,27 @@ const QS_CONFIG = getQSConfig('host', { function SmartInventoryHostList({ i18n, inventory }) { const location = useLocation(); - const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false); const { - result: { hosts, count, moduleOptions, credentialTypeId, isAdHocDisabled }, + result: { hosts, count }, error: contentError, isLoading, request: fetchHosts, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const [hostResponse, adHocOptions, cred] = await Promise.all([ - InventoriesAPI.readHosts(inventory.id, params), - InventoriesAPI.readAdHocOptions(inventory.id), - CredentialTypesAPI.read({ namespace: 'ssh' }), - ]); + const { + data: { results, count: hostCount }, + } = await InventoriesAPI.readHosts(inventory.id, params); return { - hosts: hostResponse.data.results, - count: hostResponse.data.count, - moduleOptions: adHocOptions.data.actions.GET.module_name.choices, - credentialTypeId: cred.data.results[0].id, - isAdHocDisabled: !adHocOptions.data.actions.POST, + hosts: results, + count: hostCount, }; }, [location.search, inventory.id]), { hosts: [], count: 0, - moduleOptions: [], - isAdHocDisabled: true, } ); @@ -110,38 +95,10 @@ function SmartInventoryHostList({ i18n, inventory }) { additionalControls={ inventory?.summary_fields?.user_capabilities?.adhoc ? [ - - {({ isKebabified }) => - isKebabified ? ( - setIsAdHocCommandsOpen(true)} - isDisabled={count === 0 || isAdHocDisabled} - > - {i18n._(t`Run command`)} - - ) : ( - - - - - - ) - } - , + 0} + />, ] : [] } @@ -157,16 +114,6 @@ function SmartInventoryHostList({ i18n, inventory }) { /> )} /> - {isAdHocCommandsOpen && ( - setIsAdHocCommandsOpen(false)} - credentialTypeId={credentialTypeId} - moduleOptions={moduleOptions} - /> - )} ); } diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.jsx index 60fd23d75a..6f7c743a3a 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { InventoriesAPI, CredentialTypesAPI } from '../../../api'; +import { InventoriesAPI } from '../../../api'; import { mountWithContexts, waitForElement, @@ -27,17 +27,6 @@ describe('', () => { InventoriesAPI.readHosts.mockResolvedValue({ data: mockHosts, }); - InventoriesAPI.readAdHocOptions.mockResolvedValue({ - data: { - actions: { - GET: { module_name: { choices: [['module']] } }, - POST: {}, - }, - }, - }); - CredentialTypesAPI.read.mockResolvedValue({ - data: { count: 1, results: [{ id: 1, name: 'cred' }] }, - }); await act(async () => { wrapper = mountWithContexts( @@ -60,15 +49,6 @@ describe('', () => { expect(wrapper.find('SmartInventoryHostListItem').length).toBe(3); }); - test('should have run command button', () => { - wrapper.find('DataListCheck').forEach(el => { - expect(el.props().checked).toBe(false); - }); - const runCommandsButton = wrapper.find('button[aria-label="Run command"]'); - expect(runCommandsButton.length).toBe(1); - expect(runCommandsButton.prop('disabled')).toBe(false); - }); - test('should select and deselect all items', async () => { act(() => { wrapper.find('DataListToolbar').invoke('onSelectAll')(true); @@ -97,24 +77,4 @@ describe('', () => { }); await waitForElement(wrapper, 'ContentError', el => el.length === 1); }); - test('should disable run commands button', async () => { - InventoriesAPI.readHosts.mockResolvedValue({ - data: { results: [], count: 0 }, - }); - InventoriesAPI.readAdHocOptions.mockResolvedValue({ - data: { - actions: { - GET: { module_name: { choices: [['module']] } }, - }, - }, - }); - await act(async () => { - wrapper = mountWithContexts( - - ); - }); - await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); - const runCommandsButton = wrapper.find('button[aria-label="Run command"]'); - expect(runCommandsButton.prop('disabled')).toBe(true); - }); }); diff --git a/awx/ui_next/src/screens/Inventory/shared/AddDropdown.jsx b/awx/ui_next/src/screens/Inventory/shared/AddDropdown.jsx new file mode 100644 index 0000000000..1f6820fd7f --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/AddDropdown.jsx @@ -0,0 +1,88 @@ +import React, { useState, useRef, useEffect, Fragment } from 'react'; +import { func, string, arrayOf, shape } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Dropdown, + DropdownItem, + DropdownPosition, + DropdownToggle, +} from '@patternfly/react-core'; +import { useKebabifiedMenu } from '../../../contexts/Kebabified'; + +function AddDropdown({ dropdownItems, i18n }) { + const { isKebabified } = useKebabifiedMenu(); + const [isOpen, setIsOpen] = useState(false); + const element = useRef(null); + + useEffect(() => { + const toggle = e => { + if (!isKebabified && (!element || !element.current.contains(e.target))) { + setIsOpen(false); + } + }; + + document.addEventListener('click', toggle, false); + return () => { + document.removeEventListener('click', toggle); + }; + }, [isKebabified]); + + if (isKebabified) { + return ( + + {dropdownItems.map(item => ( + + {item.title} + + ))} + + ); + } + + return ( +
+ setIsOpen(prevState => !prevState)} + > + {i18n._(t`Add`)} + + } + dropdownItems={dropdownItems.map(item => ( + + {item.title} + + ))} + /> +
+ ); +} + +AddDropdown.propTypes = { + dropdownItems: arrayOf( + shape({ + label: string.isRequired, + onAdd: func.isRequired, + key: string.isRequired, + }) + ).isRequired, +}; + +export { AddDropdown as _AddDropdown }; +export default withI18n()(AddDropdown); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.test.jsx b/awx/ui_next/src/screens/Inventory/shared/AddDropdown.test.jsx similarity index 64% rename from awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.test.jsx rename to awx/ui_next/src/screens/Inventory/shared/AddDropdown.test.jsx index 8a7d2c11d4..39b291bc8f 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.test.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/AddDropdown.test.jsx @@ -1,17 +1,27 @@ import React from 'react'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import AddHostDropdown from './AddHostDropdown'; +import AddDropdown from './AddDropdown'; -describe('', () => { +describe('', () => { let wrapper; let dropdownToggle; - const onAddNew = jest.fn(); - const onAddExisting = jest.fn(); + const dropdownItems = [ + { + onAdd: () => {}, + title: 'Add existing group', + label: 'group', + key: 'existing', + }, + { + onAdd: () => {}, + title: 'Add new group', + label: 'group', + key: 'new', + }, + ]; beforeEach(() => { - wrapper = mountWithContexts( - - ); + wrapper = mountWithContexts(); dropdownToggle = wrapper.find('DropdownToggle button'); }); diff --git a/awx/ui_next/src/screens/Inventory/shared/data.relatedGroups.json b/awx/ui_next/src/screens/Inventory/shared/data.relatedGroups.json new file mode 100644 index 0000000000..835ab95d79 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/data.relatedGroups.json @@ -0,0 +1,181 @@ +{ + "count": 3, + "results": [{ + "id": 2, + "type": "group", + "url": "/api/v2/groups/2/", + "related": { + "created_by": "/api/v2/users/10/", + "modified_by": "/api/v2/users/14/", + "variable_data": "/api/v2/groups/2/variable_data/", + "hosts": "/api/v2/groups/2/hosts/", + "potential_children": "/api/v2/groups/2/potential_children/", + "children": "/api/v2/groups/2/children/", + "all_hosts": "/api/v2/groups/2/all_hosts/", + "job_events": "/api/v2/groups/2/job_events/", + "job_host_summaries": "/api/v2/groups/2/job_host_summaries/", + "activity_stream": "/api/v2/groups/2/activity_stream/", + "inventory_sources": "/api/v2/groups/2/inventory_sources/", + "ad_hoc_commands": "/api/v2/groups/2/ad_hoc_commands/", + "inventory": "/api/v2/inventories/1/" + }, + "summary_fields": { + "inventory": { + "id": 1, + "name": " Inventory 1 Org 0", + "description": "", + "has_active_failures": false, + "total_hosts": 33, + "hosts_with_active_failures": 0, + "total_groups": 4, + "has_inventory_sources": false, + "total_inventory_sources": 0, + "inventory_sources_with_failures": 0, + "organization_id": 1, + "kind": "" + }, + "created_by": { + "id": 10, + "username": "user-4", + "first_name": "", + "last_name": "" + }, + "modified_by": { + "id": 14, + "username": "user-8", + "first_name": "", + "last_name": "" + }, + "user_capabilities": { + "edit": true, + "delete": true, + "copy": true + } + }, + "created": "2020-09-23T14:30:55.263148Z", + "modified": "2020-09-23T14:30:55.263175Z", + "name": " Group 2 Inventory 0", + "description": "", + "inventory": 1, + "variables": "" + }, + { + "id": 3, + "type": "group", + "url": "/api/v2/groups/3/", + "related": { + "created_by": "/api/v2/users/11/", + "modified_by": "/api/v2/users/15/", + "variable_data": "/api/v2/groups/3/variable_data/", + "hosts": "/api/v2/groups/3/hosts/", + "potential_children": "/api/v2/groups/3/potential_children/", + "children": "/api/v2/groups/3/children/", + "all_hosts": "/api/v2/groups/3/all_hosts/", + "job_events": "/api/v2/groups/3/job_events/", + "job_host_summaries": "/api/v2/groups/3/job_host_summaries/", + "activity_stream": "/api/v2/groups/3/activity_stream/", + "inventory_sources": "/api/v2/groups/3/inventory_sources/", + "ad_hoc_commands": "/api/v2/groups/3/ad_hoc_commands/", + "inventory": "/api/v2/inventories/1/" + }, + "summary_fields": { + "inventory": { + "id": 1, + "name": " Inventory 1 Org 0", + "description": "", + "has_active_failures": false, + "total_hosts": 33, + "hosts_with_active_failures": 0, + "total_groups": 4, + "has_inventory_sources": false, + "total_inventory_sources": 0, + "inventory_sources_with_failures": 0, + "organization_id": 1, + "kind": "" + }, + "created_by": { + "id": 11, + "username": "user-5", + "first_name": "", + "last_name": "" + }, + "modified_by": { + "id": 15, + "username": "user-9", + "first_name": "", + "last_name": "" + }, + "user_capabilities": { + "edit": true, + "delete": true, + "copy": true + } + }, + "created": "2020-09-23T14:30:55.281583Z", + "modified": "2020-09-23T14:30:55.281615Z", + "name": " Group 3 Inventory 0", + "description": "", + "inventory": 1, + "variables": "" + }, + { + "id": 4, + "type": "group", + "url": "/api/v2/groups/4/", + "related": { + "created_by": "/api/v2/users/12/", + "modified_by": "/api/v2/users/16/", + "variable_data": "/api/v2/groups/4/variable_data/", + "hosts": "/api/v2/groups/4/hosts/", + "potential_children": "/api/v2/groups/4/potential_children/", + "children": "/api/v2/groups/4/children/", + "all_hosts": "/api/v2/groups/4/all_hosts/", + "job_events": "/api/v2/groups/4/job_events/", + "job_host_summaries": "/api/v2/groups/4/job_host_summaries/", + "activity_stream": "/api/v2/groups/4/activity_stream/", + "inventory_sources": "/api/v2/groups/4/inventory_sources/", + "ad_hoc_commands": "/api/v2/groups/4/ad_hoc_commands/", + "inventory": "/api/v2/inventories/1/" + }, + "summary_fields": { + "inventory": { + "id": 1, + "name": " Inventory 1 Org 0", + "description": "", + "has_active_failures": false, + "total_hosts": 33, + "hosts_with_active_failures": 0, + "total_groups": 4, + "has_inventory_sources": false, + "total_inventory_sources": 0, + "inventory_sources_with_failures": 0, + "organization_id": 1, + "kind": "" + }, + "created_by": { + "id": 12, + "username": "user-6", + "first_name": "", + "last_name": "" + }, + "modified_by": { + "id": 16, + "username": "user-10", + "first_name": "", + "last_name": "" + }, + "user_capabilities": { + "edit": false, + "delete": true, + "copy": true + } + }, + "created": "2020-09-23T14:30:55.293574Z", + "modified": "2020-09-23T14:30:55.293603Z", + "name": " Group 4 Inventory 0", + "description": "", + "inventory": 1, + "variables": "" + } + ] +}