diff --git a/awx/ui/src/api/models/ConstructedInventories.js b/awx/ui/src/api/models/ConstructedInventories.js index b62bffd3f3..0a7af3e87f 100644 --- a/awx/ui/src/api/models/ConstructedInventories.js +++ b/awx/ui/src/api/models/ConstructedInventories.js @@ -5,6 +5,96 @@ class ConstructedInventories extends InstanceGroupsMixin(Base) { constructor(http) { super(http); this.baseUrl = 'api/v2/constructed_inventories/'; + + this.readAccessList = this.readAccessList.bind(this); + this.readAccessOptions = this.readAccessOptions.bind(this); + this.readHosts = this.readHosts.bind(this); + this.readHostDetail = this.readHostDetail.bind(this); + this.readGroups = this.readGroups.bind(this); + this.readGroupsOptions = this.readGroupsOptions.bind(this); + this.promoteGroup = this.promoteGroup.bind(this); + } + + readAccessList(id, params) { + return this.http.get(`${this.baseUrl}${id}/access_list/`, { + params, + }); + } + + readAccessOptions(id) { + return this.http.options(`${this.baseUrl}${id}/access_list/`); + } + + createHost(id, data) { + return this.http.post(`${this.baseUrl}${id}/hosts/`, data); + } + + readHosts(id, params) { + return this.http.get(`${this.baseUrl}${id}/hosts/`, { + params, + }); + } + + async readHostDetail(inventoryId, hostId) { + const { + data: { results }, + } = await this.http.get( + `${this.baseUrl}${inventoryId}/hosts/?id=${hostId}` + ); + + if (Array.isArray(results) && results.length) { + return results[0]; + } + + throw new Error( + `How did you get here? Host not found for Inventory ID: ${inventoryId}` + ); + } + + readGroups(id, params) { + return this.http.get(`${this.baseUrl}${id}/groups/`, { + params, + }); + } + + readGroupsOptions(id) { + return this.http.options(`${this.baseUrl}${id}/groups/`); + } + + readHostsOptions(id) { + return this.http.options(`${this.baseUrl}${id}/hosts/`); + } + + promoteGroup(inventoryId, groupId) { + return this.http.post(`${this.baseUrl}${inventoryId}/groups/`, { + id: groupId, + disassociate: true, + }); + } + + readAdHocOptions(inventoryId) { + return this.http.options(`${this.baseUrl}${inventoryId}/ad_hoc_commands/`); + } + + launchAdHocCommands(inventoryId, values) { + return this.http.post( + `${this.baseUrl}${inventoryId}/ad_hoc_commands/`, + values + ); + } + + associateLabel(id, label, orgId) { + return this.http.post(`${this.baseUrl}${id}/labels/`, { + name: label.name, + organization: orgId, + }); + } + + disassociateLabel(id, label) { + return this.http.post(`${this.baseUrl}${id}/labels/`, { + id: label.id, + disassociate: true, + }); } } diff --git a/awx/ui/src/screens/Inventory/ConstructedInventory.js b/awx/ui/src/screens/Inventory/ConstructedInventory.js index 58b33b96d2..085a6c8da5 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventory.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventory.js @@ -20,10 +20,10 @@ import JobList from 'components/JobList'; import RelatedTemplateList from 'components/RelatedTemplateList'; import { ResourceAccessList } from 'components/ResourceAccessList'; import RoutedTabs from 'components/RoutedTabs'; -import ConstructedInventoryDetail from './ConstructedInventoryDetail'; -import ConstructedInventoryEdit from './ConstructedInventoryEdit'; -import ConstructedInventoryGroups from './ConstructedInventoryGroups'; -import ConstructedInventoryHosts from './ConstructedInventoryHosts'; +import ConstructedInventoryDetail from './InventoryDetail'; +import ConstructedInventoryEdit from './InventoryEdit'; +import ConstructedInventoryGroups from './InventoryGroups'; +import ConstructedInventoryHosts from './InventoryHosts'; import { getInventoryPath } from './shared/utils'; function ConstructedInventory({ setBreadcrumb }) { @@ -111,7 +111,12 @@ function ConstructedInventory({ setBreadcrumb }) { } let showCardHeader = true; - if (['edit'].some((name) => location.pathname.includes(name))) { + + if ( + ['edit', 'add', 'groups/', 'hosts/', 'sources/'].some((name) => + location.pathname.includes(name) + ) + ) { showCardHeader = false; } @@ -152,15 +157,21 @@ function ConstructedInventory({ setBreadcrumb }) { , - + , - + , { - const [response, sourceInvResponse, options] = await Promise.all([ - InventoriesAPI.readInstanceGroups(inventory.id), - InventoriesAPI.readSourceInventories(inventory.id), - ConstructedInventoriesAPI.readOptions(inventory.id), - ]); - - return { - instanceGroups: response.data.results, - sourceInventories: sourceInvResponse.data.results, - actions: options.data.actions.GET, - }; - }, [inventory.id]), - { - instanceGroups: [], - sourceInventories: [], - actions: {}, - isLoading: true, - } - ); - - useEffect(() => { - fetchRelatedDetails(); - }, [fetchRelatedDetails]); - - const { request: deleteInventory, error: deleteError } = useRequest( - useCallback(async () => { - await InventoriesAPI.destroy(inventory.id); - history.push(`/inventories`); - }, [inventory.id, history]) - ); - - const { error, dismissError } = useDismissableError(deleteError); - - const { organization, user_capabilities: userCapabilities } = - inventory.summary_fields; - - const deleteDetailsRequests = - relatedResourceDeleteRequests.inventory(inventory); - - if (isLoading) { - return ; - } - - if (contentError) { - return ; - } - - return ( - - - - - - - - {organization.name} - - } - /> - - - - - - - {instanceGroups && ( - } - isEmpty={instanceGroups.length === 0} - dataCy="constructed-inventory-instance-groups" - /> - )} - {inventory.prevent_instance_group_fallback && ( - - {inventory.prevent_instance_group_fallback && ( - - {t`Prevent Instance Group Fallback`} - - - )} - - } - /> - )} - - {inventory.summary_fields.labels?.results?.map((l) => ( - - {l.name} - - ))} - - } - isEmpty={inventory.summary_fields.labels?.results?.length === 0} - /> - - {sourceInventories?.map((sourceInventory) => ( - - - {sourceInventory.name} - - - ))} - - } - isEmpty={sourceInventories?.length === 0} - /> - - - - - - {userCapabilities.edit && ( - - {t`Edit`} - - )} - {userCapabilities.delete && ( - - {t`Delete`} - - )} - - {error && ( - - {t`Failed to delete inventory.`} - - - )} - - ); -} - -ConstructedInventoryDetail.propTypes = { - inventory: Inventory.isRequired, -}; - -export default ConstructedInventoryDetail; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.test.js deleted file mode 100644 index 5d924a2790..0000000000 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.test.js +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { InventoriesAPI, CredentialTypesAPI } from 'api'; - -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import ConstructedInventoryDetail from './ConstructedInventoryDetail'; - -jest.mock('../../../api'); - -const mockInventory = { - id: 1, - type: 'inventory', - summary_fields: { - organization: { - id: 1, - name: 'The Organization', - description: '', - }, - created_by: { - username: 'the_creator', - id: 2, - }, - modified_by: { - username: 'the_modifier', - id: 3, - }, - user_capabilities: { - edit: true, - delete: true, - copy: true, - adhoc: true, - }, - }, - created: '2019-10-04T16:56:48.025455Z', - modified: '2019-10-04T16:56:48.025468Z', - name: 'Constructed Inv', - description: '', - organization: 1, - kind: 'constructed', - has_active_failures: false, - total_hosts: 0, - hosts_with_active_failures: 0, - total_groups: 0, - groups_with_active_failures: 0, - has_inventory_sources: false, - total_inventory_sources: 0, - inventory_sources_with_failures: 0, - pending_deletion: false, - prevent_instance_group_fallback: false, - update_cache_timeout: 0, - limit: '', - verbosity: 1, -}; - -describe('', () => { - test('initially renders successfully', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts( - - ); - }); - expect(wrapper.length).toBe(1); - expect(wrapper.find('ConstructedInventoryDetail').length).toBe(1); - }); -}); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/index.js b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/index.js deleted file mode 100644 index efe8b49508..0000000000 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ConstructedInventoryDetail'; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.js b/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.js deleted file mode 100644 index 964dfa9062..0000000000 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.js +++ /dev/null @@ -1,13 +0,0 @@ -/* eslint i18next/no-literal-string: "off" */ -import React from 'react'; -import { CardBody } from 'components/Card'; - -function ConstructedInventoryGroups() { - return ( - - Coming Soon! - - ); -} - -export default ConstructedInventoryGroups; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.test.js deleted file mode 100644 index db2720ff44..0000000000 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.test.js +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import ConstructedInventoryGroups from './ConstructedInventoryGroups'; - -describe('', () => { - test('initially renders successfully', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); - }); - expect(wrapper.length).toBe(1); - expect(wrapper.find('ConstructedInventoryGroups').length).toBe(1); - }); -}); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/index.js b/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/index.js deleted file mode 100644 index 7f1b4343b2..0000000000 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ConstructedInventoryGroups'; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.js b/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.js deleted file mode 100644 index 56f0c801b8..0000000000 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.js +++ /dev/null @@ -1,13 +0,0 @@ -/* eslint i18next/no-literal-string: "off" */ -import React from 'react'; -import { CardBody } from 'components/Card'; - -function ConstructedInventoryHosts() { - return ( - - Coming Soon! - - ); -} - -export default ConstructedInventoryHosts; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.test.js deleted file mode 100644 index 0d6b3d6f13..0000000000 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.test.js +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import ConstructedInventoryHosts from './ConstructedInventoryHosts'; - -describe('', () => { - test('initially renders successfully', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); - }); - expect(wrapper.length).toBe(1); - expect(wrapper.find('ConstructedInventoryHosts').length).toBe(1); - }); -}); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/index.js b/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/index.js deleted file mode 100644 index 68464720fb..0000000000 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ConstructedInventoryHosts'; diff --git a/awx/ui/src/screens/Inventory/Inventory.js b/awx/ui/src/screens/Inventory/Inventory.js index c35a92d375..9784117d6b 100644 --- a/awx/ui/src/screens/Inventory/Inventory.js +++ b/awx/ui/src/screens/Inventory/Inventory.js @@ -8,6 +8,7 @@ import { Link, useLocation, useRouteMatch, + useParams, } from 'react-router-dom'; import { CaretLeftIcon } from '@patternfly/react-icons'; import { Card, PageSection } from '@patternfly/react-core'; @@ -30,14 +31,15 @@ function Inventory({ setBreadcrumb }) { const [hasContentLoading, setHasContentLoading] = useState(true); const [inventory, setInventory] = useState(null); const location = useLocation(); + const { id: inventoryId } = useParams(); const match = useRouteMatch({ - path: '/inventories/inventory/:id', + path: `/inventories/:inventoryType/:id`, }); useEffect(() => { async function fetchData() { try { - const { data } = await InventoriesAPI.readDetail(match.params.id); + const { data } = await InventoriesAPI.readDetail(inventoryId); setBreadcrumb(data); setInventory(data); } catch (error) { @@ -48,7 +50,7 @@ function Inventory({ setBreadcrumb }) { } fetchData(); - }, [match.params.id, location.pathname, setBreadcrumb]); + }, [inventoryId, location.pathname, setBreadcrumb]); const tabsArray = [ { @@ -185,10 +187,8 @@ function Inventory({ setBreadcrumb }) { , - {match.params.id && ( - + {inventoryId && ( + {t`View Inventory Details`} )} diff --git a/awx/ui/src/screens/Inventory/InventoryDetail/InventoryDetail.js b/awx/ui/src/screens/Inventory/InventoryDetail/InventoryDetail.js index 0dd7803415..106b7e5065 100644 --- a/awx/ui/src/screens/Inventory/InventoryDetail/InventoryDetail.js +++ b/awx/ui/src/screens/Inventory/InventoryDetail/InventoryDetail.js @@ -24,6 +24,7 @@ import useRequest, { useDismissableError } from 'hooks/useRequest'; import { Inventory } from 'types'; import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails'; import InstanceGroupLabels from 'components/InstanceGroupLabels'; +import { VERBOSITY } from 'components/VerbositySelectField'; import getHelpText from '../shared/Inventory.helptext'; function InventoryDetail({ inventory }) { @@ -102,6 +103,7 @@ function InventoryDetail({ inventory }) { } /> + {instanceGroups && ( )} + + + {renderOptionsField && ( )} - {/* Update delete modal to show dependencies https://github.com/ansible/awx/issues/5546 */} {error && ( { @@ -50,22 +50,22 @@ function InventoryGroup({ setBreadcrumb, inventory }) { {t`Back to Groups`} > ), - link: `/inventories/inventory/${inventory.id}/groups`, + link: `/inventories/${inventoryType}/${inventoryId}/groups`, id: 99, }, { name: t`Details`, - link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup?.id}/details`, + link: `/inventories/${inventoryType}/${inventoryId}/groups/${inventoryGroup?.id}/details`, id: 0, }, { name: t`Related Groups`, - link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup?.id}/nested_groups`, + link: `/inventories/${inventoryType}/${inventoryId}/groups/${inventoryGroup?.id}/nested_groups`, id: 1, }, { name: t`Hosts`, - link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup?.id}/nested_hosts`, + link: `/inventories/${inventoryType}/${inventoryId}/groups/${inventoryGroup?.id}/nested_hosts`, id: 2, }, ]; @@ -105,32 +105,32 @@ function InventoryGroup({ setBreadcrumb, inventory }) { {showCardHeader && } {inventoryGroup && [ , , , , @@ -138,7 +138,7 @@ function InventoryGroup({ setBreadcrumb, inventory }) { {inventory && ( - + {t`View Inventory Details`} )} diff --git a/awx/ui/src/screens/Inventory/InventoryGroup/InventoryGroup.test.js b/awx/ui/src/screens/Inventory/InventoryGroup/InventoryGroup.test.js index ee468bf7d4..b72fc0766d 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroup/InventoryGroup.test.js +++ b/awx/ui/src/screens/Inventory/InventoryGroup/InventoryGroup.test.js @@ -11,15 +11,16 @@ import { import InventoryGroup from './InventoryGroup'; jest.mock('../../../api'); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ - id: 1, - groupId: 2, - }), -})); - describe('', () => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + inventoryType: 'inventory', + }), + })); + let wrapper; let history; const inventory = { id: 1, name: 'Foo' }; @@ -41,11 +42,11 @@ describe('', () => { }, }); history = createMemoryHistory({ - initialEntries: ['/inventories/inventory/1/groups/1/details'], + initialEntries: [`/inventories/inventory/1/groups/1/details`], }); await act(async () => { wrapper = mountWithContexts( - + {}} inventory={inventory} /> , { context: { router: { history } } } @@ -63,7 +64,7 @@ describe('', () => { expect(routedTabs).toHaveLength(1); const tabs = routedTabs.prop('tabsArray'); - expect(tabs[0].link).toEqual('/inventories/inventory/1/groups'); + expect(tabs[0].link).toEqual(`/inventories/inventory/1/groups`); expect(tabs[1].name).toEqual('Details'); expect(tabs[2].name).toEqual('Related Groups'); expect(tabs[3].name).toEqual('Hosts'); @@ -71,7 +72,7 @@ describe('', () => { test('should show content error when user attempts to navigate to erroneous route', async () => { history = createMemoryHistory({ - initialEntries: ['/inventories/inventory/1/groups/1/foobar'], + initialEntries: [`/inventories/inventory/1/groups/1/foobar`], }); await act(async () => { wrapper = mountWithContexts( @@ -92,3 +93,59 @@ describe('', () => { await waitForElement(wrapper, 'ContentError', (el) => el.length === 1); }); }); + +describe('constructed inventory', () => { + let wrapper; + let history; + const inventory = { id: 1, name: 'Foo' }; + + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + inventoryType: 'constructed_inventory', + }), + })); + + beforeEach(async () => { + GroupsAPI.readDetail.mockResolvedValue({ + data: { + id: 1, + name: 'Foo', + description: 'Bar', + variables: 'bizz: buzz', + summary_fields: { + inventory: { id: 1 }, + created_by: { id: 1, username: 'Athena' }, + modified_by: { id: 1, username: 'Apollo' }, + }, + created: '2020-04-25T01:23:45.678901Z', + modified: '2020-04-25T01:23:45.678901Z', + }, + }); + history = createMemoryHistory({ + initialEntries: [`/inventories/constructed_inventory/1/groups/1/details`], + }); + + await act(async () => { + wrapper = mountWithContexts( + + {}} inventory={inventory} /> + , + { context: { router: { history } } } + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + }); + test('Constructed Inventory expect all tabs to exist, including Back to Groups', async () => { + const routedTabs = wrapper.find('RoutedTabs'); + expect(routedTabs).toHaveLength(1); + + const tabs = routedTabs.prop('tabsArray'); + expect(tabs[0].link).toEqual(`/inventories/constructed_inventory/1/groups`); + expect(tabs[1].name).toEqual('Details'); + expect(tabs[2].name).toEqual('Related Groups'); + expect(tabs[3].name).toEqual('Hosts'); + }); +}); diff --git a/awx/ui/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.js b/awx/ui/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.js index 94fd284076..fe1958ca1a 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.js +++ b/awx/ui/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.js @@ -1,9 +1,8 @@ import React, { useState } from 'react'; import { t } from '@lingui/macro'; - +import { useHistory, useParams } from 'react-router-dom'; import { Button } from '@patternfly/react-core'; -import { useHistory, useParams } from 'react-router-dom'; import { VariablesDetail } from 'components/CodeEditor'; import { CardBody, CardActionsRow } from 'components/Card'; import ErrorDetail from 'components/ErrorDetail'; @@ -12,6 +11,7 @@ import { DetailList, Detail, UserDateDetail } from 'components/DetailList'; import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal'; function InventoryGroupDetail({ inventoryGroup }) { + const { inventoryType } = useParams(); const { summary_fields: { created_by, modified_by, user_capabilities }, created, @@ -47,31 +47,33 @@ function InventoryGroupDetail({ inventoryGroup }) { user={modified_by} /> - - {user_capabilities?.edit && ( - - history.push( - `/inventories/inventory/${params.id}/groups/${params.groupId}/edit` - ) - } - > - {t`Edit`} - - )} - {user_capabilities?.delete && ( - - history.push(`/inventories/inventory/${params.id}/groups`) - } - /> - )} - + {inventoryType !== 'constructed_inventory' && ( + + {user_capabilities?.edit && ( + + history.push( + `/inventories/inventory/${params.id}/groups/${params.groupId}/edit` + ) + } + > + {t`Edit`} + + )} + {user_capabilities?.delete && ( + + history.push(`/inventories/inventory/${params.id}/groups`) + } + /> + )} + + )} {error && ( ', () => { let history; describe('User has full permissions', () => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 3, + inventoryType: 'inventory', + }), + })); beforeEach(async () => { await act(async () => { history = createMemoryHistory({ @@ -116,6 +124,14 @@ describe('', () => { }); describe('User has read-only permissions', () => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 3, + inventoryType: 'inventory', + }), + })); test('should hide edit/delete buttons', async () => { const readOnlyGroup = { ...inventoryGroup, @@ -159,4 +175,48 @@ describe('', () => { expect(wrapper.find('button[aria-label="Delete"]').length).toBe(0); }); }); + describe('Cannot edit or delete constructed inventory group', () => { + beforeEach(async () => { + await act(async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/groups/1/details'], + }); + wrapper = mountWithContexts( + + + , + { + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { + id: 1, + group: 2, + inventoryType: 'constructed_inventory', + }, + }, + }, + }, + }, + } + ); + await waitForElement( + wrapper, + 'ContentLoading', + (el) => el.length === 0 + ); + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + test('should not show edit button', () => { + const editButton = wrapper.find('Button[aria-label="edit"]'); + expect(editButton.length).toBe(0); + expect(wrapper.find('Button[aria-label="delete"]').length).toBe(0); + }); + }); }); diff --git a/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.js b/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.js index 2825715ede..9f734be8d9 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.js +++ b/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.js @@ -34,7 +34,7 @@ const QS_CONFIG = getQSConfig('host', { function InventoryGroupHostList() { const [isAdHocLaunchLoading, setIsAdHocLaunchLoading] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); - const { id: inventoryId, groupId } = useParams(); + const { id: inventoryId, groupId, inventoryType } = useParams(); const location = useLocation(); const { @@ -259,8 +259,8 @@ function InventoryGroupHostList() { key={host.id} rowIndex={index} host={host} - detailUrl={`/inventories/inventory/${inventoryId}/hosts/${host.id}/details`} - editUrl={`/inventories/inventory/${inventoryId}/hosts/${host.id}/edit`} + detailUrl={`/inventories/${inventoryType}/${inventoryId}/hosts/${host.id}/details`} + editUrl={`/inventories/${inventoryType}/${inventoryId}/hosts/${host.id}/edit`} isSelected={selected.some((row) => row.id === host.id)} onSelect={() => handleSelect(host)} /> diff --git a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupItem.js b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupItem.js index 966f4fe2a5..2f8b5b2ab4 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupItem.js +++ b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupItem.js @@ -1,25 +1,20 @@ import React from 'react'; -import { bool, func, number, oneOfType, string } from 'prop-types'; +import { bool, func } from 'prop-types'; import { t } from '@lingui/macro'; import { Button } from '@patternfly/react-core'; import { Tr, Td } from '@patternfly/react-table'; -import { Link } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; import { PencilAltIcon } from '@patternfly/react-icons'; import { ActionsTd, ActionItem } from 'components/PaginatedTable'; import { Group } from 'types'; -function InventoryGroupItem({ - group, - inventoryId, - isSelected, - onSelect, - rowIndex, -}) { +function InventoryGroupItem({ group, isSelected, onSelect, rowIndex }) { + const { id: inventoryId, inventoryType } = useParams(); const labelId = `check-action-${group.id}`; - const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/details`; - const editUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/edit`; + const detailUrl = `/inventories/${inventoryType}/${inventoryId}/groups/${group.id}/details`; + const editUrl = `/inventories/${inventoryType}/${inventoryId}/groups/${group.id}/edit`; return ( @@ -36,29 +31,30 @@ function InventoryGroupItem({ {group.name} - - - + - - - - + + + + + + )} ); } InventoryGroupItem.propTypes = { group: Group.isRequired, - inventoryId: oneOfType([number, string]).isRequired, isSelected: bool.isRequired, onSelect: func.isRequired, }; diff --git a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupItem.test.js b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupItem.test.js index cb4956e44a..49f4fe22b5 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupItem.test.js +++ b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupItem.test.js @@ -1,4 +1,6 @@ import React from 'react'; +import { Route } from 'react-router-dom'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import InventoryGroupItem from './InventoryGroupItem'; @@ -57,4 +59,39 @@ describe('', () => { ); expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); }); + test('edit button should be hidden from constructed inventory group', async () => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ id: 42, inventoryType: 'constructed_inventory' }), + })); + const mockGroup = { + id: 2, + type: 'group', + name: 'foo', + inventory: 1, + summary_fields: { + user_capabilities: { + edit: true, + }, + }, + }; + + await act(async () => { + wrapper = mountWithContexts( + + + + {}} + /> + + + + ); + }); + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); }); diff --git a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroups.js b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroups.js index ae19f09660..97eef35dc6 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroups.js +++ b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroups.js @@ -16,11 +16,14 @@ function InventoryGroups({ setBreadcrumb, inventory }) { inventory={inventory} /> - + - - + + ); diff --git a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupsList.js b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupsList.js index 77bd67c404..e15316fb2d 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupsList.js +++ b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupsList.js @@ -5,7 +5,7 @@ import { Tooltip } from '@patternfly/react-core'; import { getQSConfig, parseQueryString } from 'util/qs'; import useSelected from 'hooks/useSelected'; import useRequest from 'hooks/useRequest'; -import { InventoriesAPI } from 'api'; +import { ConstructedInventoriesAPI, InventoriesAPI } from 'api'; import DataListToolbar from 'components/DataListToolbar'; import PaginatedTable, { HeaderRow, @@ -29,7 +29,7 @@ function cannotDelete(item) { function InventoryGroupsList() { const location = useLocation(); - const { id: inventoryId } = useParams(); + const { id: inventoryId, inventoryType } = useParams(); const [isAdHocLaunchLoading, setIsAdHocLaunchLoading] = useState(false); const { @@ -104,8 +104,10 @@ function InventoryGroupsList() { }; const canAdd = - actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); - + actions && + Object.prototype.hasOwnProperty.call(actions, 'POST') && + inventoryType !== 'constructed_inventory'; + const canDelete = inventoryType !== 'constructed_inventory'; return ( {t`Name`} - {t`Actions`} + {inventoryType !== 'constructed_inventory' && ( + {t`Actions`} + )} } renderRow={(item, index) => ( row.id === item.id)} onSelect={() => handleSelect(item)} rowIndex={index} @@ -177,20 +180,28 @@ function InventoryGroupsList() { />, ] : []), - - - { - fetchData(); - clearSelected(); - }} - /> - - , + ...(canDelete + ? [ + + + { + fetchData(); + clearSelected(); + }} + /> + + , + ] + : []), ]} /> )} diff --git a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.js b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.js index 5743fc96c8..bd05a4b1f5 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.js +++ b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.js @@ -10,12 +10,6 @@ import { import InventoryGroupsList from './InventoryGroupsList'; jest.mock('../../../api'); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ - id: 1, - }), -})); const mockGroups = [ { id: 1, @@ -60,7 +54,14 @@ const mockGroups = [ describe('', () => { let wrapper; - + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + inventoryType: 'inventory', + }), + })); beforeEach(async () => { InventoriesAPI.readGroups.mockResolvedValue({ data: { @@ -96,7 +97,7 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - + , { @@ -316,3 +317,78 @@ describe(' error handling', () => { }); }); }); + +describe('Constructed Inventory group', () => { + let wrapper; + let history; + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + inventoryType: 'constructed_inventory', + }), + })); + + beforeEach(async () => { + InventoriesAPI.readGroups.mockResolvedValue({ + data: { + count: mockGroups.length, + results: mockGroups, + }, + }); + InventoriesAPI.readGroupsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + InventoriesAPI.readAdHocOptions.mockResolvedValue({ + data: { + actions: { + GET: { + module_name: { + choices: [ + ['command', 'command'], + ['shell', 'shell'], + ], + }, + }, + POST: {}, + }, + }, + }); + history = createMemoryHistory({ + initialEntries: ['/inventories/constructed_inventory/3/groups'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { + history, + route: { + location: history.location, + }, + }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + test('should not show add button', () => { + expect(wrapper.find('ToolbarAddButton').length).toBe(0); + expect(wrapper.find('ToolbarDeleteButton').length).toBe(0); + expect(wrapper.find('AdHocCommands').length).toBe(1); + }); +}); diff --git a/awx/ui/src/screens/Inventory/InventoryHost/InventoryHost.js b/awx/ui/src/screens/Inventory/InventoryHost/InventoryHost.js index af010a4c04..815c70221a 100644 --- a/awx/ui/src/screens/Inventory/InventoryHost/InventoryHost.js +++ b/awx/ui/src/screens/Inventory/InventoryHost/InventoryHost.js @@ -8,6 +8,7 @@ import { Link, useRouteMatch, useLocation, + useParams, } from 'react-router-dom'; import { Card } from '@patternfly/react-core'; import { CaretLeftIcon } from '@patternfly/react-icons'; @@ -25,9 +26,9 @@ import InventoryHostGroups from '../InventoryHostGroups'; function InventoryHost({ setBreadcrumb, inventory }) { const location = useLocation(); - const match = useRouteMatch('/inventories/inventory/:id/hosts/:hostId'); - const hostListUrl = `/inventories/inventory/${inventory.id}/hosts`; - + const { hostId, id: inventoryId, inventoryType } = useParams(); + const match = useRouteMatch('/inventories/:inventoryType/:id/hosts/:hostId'); + const hostListUrl = `/inventories/${inventoryType}/${inventory.id}/hosts`; const { result: { host }, error: contentError, @@ -35,14 +36,11 @@ function InventoryHost({ setBreadcrumb, inventory }) { request: fetchHost, } = useRequest( useCallback(async () => { - const response = await InventoriesAPI.readHostDetail( - inventory.id, - match.params.hostId - ); + const response = await InventoriesAPI.readHostDetail(inventoryId, hostId); return { host: response, }; - }, [inventory.id, match.params.hostId]), + }, [inventoryId, hostId]), { host: null, } @@ -120,37 +118,37 @@ function InventoryHost({ setBreadcrumb, inventory }) { {!isLoading && host && ( diff --git a/awx/ui/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.js b/awx/ui/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.js index d7bfa590a2..eee83ccd9a 100644 --- a/awx/ui/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.js +++ b/awx/ui/src/screens/Inventory/InventoryHostDetail/InventoryHostDetail.js @@ -1,6 +1,6 @@ import 'styled-components/macro'; import React, { useState } from 'react'; -import { Link, useHistory } from 'react-router-dom'; +import { Link, useHistory, useParams } from 'react-router-dom'; import { t } from '@lingui/macro'; import { Button } from '@patternfly/react-core'; @@ -16,6 +16,7 @@ import { HostsAPI } from 'api'; import HostToggle from 'components/HostToggle'; function InventoryHostDetail({ host }) { + const { inventoryType } = useParams(); const { created, description, @@ -92,25 +93,27 @@ function InventoryHostDetail({ host }) { dataCy="inventory-host-detail-variables" /> - - {user_capabilities?.edit && ( - - {t`Edit`} - - )} - {user_capabilities?.delete && ( - handleHostDelete()} - /> - )} - + {inventoryType !== 'constructed_inventory' && ( + + {user_capabilities?.edit && ( + + {t`Edit`} + + )} + {user_capabilities?.delete && ( + handleHostDelete()} + /> + )} + + )} {deletionError && ( ', () => { +describe('User has edit permissions', () => { let wrapper; - describe('User has edit permissions', () => { - beforeAll(() => { + beforeEach(async () => { + await act(async () => { wrapper = mountWithContexts(); }); + }); + test('should render Details', async () => { + function assertDetail(label, value) { + expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); + expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); + } - test('should render Details', async () => { - function assertDetail(label, value) { - expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); - expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); - } - - assertDetail('Name', 'localhost'); - assertDetail('Description', 'localhost description'); - assertDetail('Created', '10/28/2019, 9:26:54 PM'); - assertDetail('Last Modified', '10/29/2019, 8:18:41 PM'); - expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength( - 1 - ); - }); - - test('should show edit button for users with edit permission', () => { - const editButton = wrapper.find('Button[aria-label="edit"]'); - expect(editButton.text()).toEqual('Edit'); - expect(editButton.prop('to')).toBe( - '/inventories/inventory/3/hosts/2/edit' - ); - }); - - test('expected api call is made for delete', async () => { - await act(async () => { - wrapper.find('DeleteButton').invoke('onConfirm')(); - }); - expect(HostsAPI.destroy).toHaveBeenCalledTimes(1); - }); - - test('Error dialog shown for failed deletion', async () => { - HostsAPI.destroy.mockImplementationOnce(() => - Promise.reject(new Error()) - ); - await act(async () => { - wrapper.find('DeleteButton').invoke('onConfirm')(); - }); - await waitForElement( - wrapper, - 'Modal[title="Error!"]', - (el) => el.length === 1 - ); - await act(async () => { - wrapper.find('Modal[title="Error!"]').invoke('onClose')(); - }); - await waitForElement( - wrapper, - 'Modal[title="Error!"]', - (el) => el.length === 0 - ); - }); + assertDetail('Name', 'localhost'); + assertDetail('Description', 'localhost description'); + assertDetail('Created', '10/28/2019, 9:26:54 PM'); + assertDetail('Last Modified', '10/29/2019, 8:18:41 PM'); + expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength(1); }); - describe('User has read-only permissions', () => { - beforeAll(() => { - const readOnlyHost = { - ...mockHost, - summary_fields: { - ...mockHost.summary_fields, - user_capabilities: { - ...mockHost.summary_fields.user_capabilities, - }, - }, - }; - readOnlyHost.summary_fields.user_capabilities.edit = false; - readOnlyHost.summary_fields.recent_jobs = []; - wrapper = mountWithContexts(); - }); + test('should show edit button for users with edit permission', () => { + const editButton = wrapper.find('Button[aria-label="edit"]'); + expect(editButton.text()).toEqual('Edit'); + expect(editButton.prop('to')).toBe('/inventories/inventory/3/hosts/2/edit'); + }); - test('should hide activity stream when there are no recent jobs', async () => { - expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength( - 0 - ); - const activity_detail = wrapper.find(`Detail[label="Activity"]`).at(0); - expect(activity_detail.prop('isEmpty')).toEqual(true); + test('expected api call is made for delete', async () => { + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); }); + expect(HostsAPI.destroy).toHaveBeenCalledTimes(1); + }); - test('should hide edit button for users without edit permission', async () => { - expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0); + test('Error dialog shown for failed deletion', async () => { + HostsAPI.destroy.mockImplementationOnce(() => Promise.reject(new Error())); + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); }); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + (el) => el.length === 1 + ); + await act(async () => { + wrapper.find('Modal[title="Error!"]').invoke('onClose')(); + }); + await waitForElement( + wrapper, + 'Modal[title="Error!"]', + (el) => el.length === 0 + ); + }); +}); + +describe('User has read-only permissions', () => { + let wrapper; + beforeEach(async () => { + const readOnlyHost = { + ...mockHost, + summary_fields: { + ...mockHost.summary_fields, + user_capabilities: { + ...mockHost.summary_fields.user_capabilities, + }, + }, + }; + readOnlyHost.summary_fields.user_capabilities.edit = false; + readOnlyHost.summary_fields.recent_jobs = []; + await act(async () => { + wrapper = mountWithContexts(); + }); + }); + + test('should hide activity stream when there are no recent jobs', async () => { + expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength(0); + const activity_detail = wrapper.find(`Detail[label="Activity"]`).at(0); + expect(activity_detail.prop('isEmpty')).toEqual(true); + }); + + test('should hide edit button for users without edit permission', async () => { + expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0); + }); +}); + +describe('Cannot delete a constructed inventory', () => { + let wrapper; + let history; + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 42, + hostId: 3, + inventoryType: 'constructed_inventory', + }), + })); + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: [`/inventories/constructed_inventory/1/hosts/1/details`], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { context: { router: { history } } } + ); + }); + }); + test('should not show edit button', () => { + const editButton = wrapper.find('Button[aria-label="edit"]'); + expect(editButton.length).toBe(0); + expect(wrapper.find('Button[aria-label="delete"]').length).toBe(0); }); }); diff --git a/awx/ui/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupItem.js b/awx/ui/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupItem.js index 1d48038c17..3013ac7cf1 100644 --- a/awx/ui/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupItem.js +++ b/awx/ui/src/screens/Inventory/InventoryHostGroups/InventoryHostGroupItem.js @@ -1,24 +1,19 @@ import React from 'react'; -import { bool, func, number, oneOfType, string } from 'prop-types'; +import { bool, func } from 'prop-types'; import { t } from '@lingui/macro'; import { Button } from '@patternfly/react-core'; import { Tr, Td } from '@patternfly/react-table'; -import { Link } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; import { PencilAltIcon } from '@patternfly/react-icons'; import { ActionsTd, ActionItem } from 'components/PaginatedTable'; import { Group } from 'types'; -function InventoryHostGroupItem({ - group, - inventoryId, - isSelected, - onSelect, - rowIndex, -}) { +function InventoryHostGroupItem({ group, isSelected, onSelect, rowIndex }) { + const { id: inventoryId, inventoryType } = useParams(); const labelId = `check-action-${group.id}`; - const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/details`; - const editUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/edit`; + const detailUrl = `/inventories/${inventoryType}/${inventoryId}/groups/${group.id}/details`; + const editUrl = `/inventories/${inventoryType}/${inventoryId}/groups/${group.id}/edit`; return ( - - - - - + + + + + )} {dismissableError && ( diff --git a/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostList.js b/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostList.js index b1a3d6e720..f952c09b5a 100644 --- a/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostList.js +++ b/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostList.js @@ -26,7 +26,7 @@ const QS_CONFIG = getQSConfig('host', { function InventoryHostList() { const [isAdHocLaunchLoading, setIsAdHocLaunchLoading] = useState(false); - const { id } = useParams(); + const { id, inventoryType } = useParams(); const { search } = useLocation(); const { @@ -100,8 +100,10 @@ function InventoryHostList() { }; const canAdd = - actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); - + actions && + Object.prototype.hasOwnProperty.call(actions, 'POST') && + inventoryType !== 'constructed_inventory'; + const canDelete = inventoryType !== 'constructed_inventory'; return ( <> , ] : []), - , + ...(canDelete + ? [ + , + ] + : []), ]} /> )} @@ -179,8 +185,8 @@ function InventoryHostList() { row.id === host.id)} onSelect={() => handleSelect(host)} rowIndex={index} diff --git a/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostList.test.js b/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostList.test.js index e5851bd5c3..b35eee85b2 100644 --- a/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostList.test.js +++ b/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostList.test.js @@ -1,4 +1,6 @@ import React from 'react'; +import { Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; import { act } from 'react-dom/test-utils'; import { InventoriesAPI, HostsAPI } from 'api'; import { @@ -359,3 +361,80 @@ describe('', () => { expect(wrapper.find('AdHocCommands')).toHaveLength(0); }); }); + +describe('Should not show add button for constructed inventory host list', () => { + let wrapper; + let history; + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + inventoryType: 'constructed_inventory', + }), + })); + + beforeEach(async () => { + InventoriesAPI.readHosts.mockResolvedValue({ + data: { + count: mockHosts.length, + results: mockHosts, + }, + }); + InventoriesAPI.readHostsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: ['first_key__search', 'ansible_facts'], + }, + }); + + InventoriesAPI.readAdHocOptions.mockResolvedValue({ + data: { + actions: { + GET: { + module_name: { + choices: [ + ['command', 'command'], + ['shell', 'shell'], + ], + }, + }, + POST: {}, + }, + }, + }); + history = createMemoryHistory({ + initialEntries: ['/inventories/constructed_inventory/3/hosts'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { + history, + route: { + location: history.location, + }, + }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + test('should not show add button', () => { + expect(wrapper.find('ToolbarAddButton').length).toBe(0); + expect(wrapper.find('ToolbarDeleteButton').length).toBe(0); + expect(wrapper.find('AdHocCommands').length).toBe(1); + }); +}); diff --git a/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHosts.js b/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHosts.js index a15fbebf6d..94ddc20c97 100644 --- a/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHosts.js +++ b/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHosts.js @@ -8,14 +8,14 @@ import InventoryHostList from './InventoryHostList'; function InventoryHosts({ setBreadcrumb, inventory }) { return ( - + - + - - + + ); diff --git a/awx/ui/src/screens/Inventory/InventoryHosts/index.js b/awx/ui/src/screens/Inventory/InventoryHosts/index.js index 0cb4fe95bc..6d33814f29 100644 --- a/awx/ui/src/screens/Inventory/InventoryHosts/index.js +++ b/awx/ui/src/screens/Inventory/InventoryHosts/index.js @@ -1 +1 @@ -export { default } from './InventoryHostList'; +export { default } from './InventoryHosts'; diff --git a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.js b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.js index 98aa701e00..8a542dc6f0 100644 --- a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.js +++ b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.js @@ -33,7 +33,7 @@ function InventoryRelatedGroupList() { const [isAdHocLaunchLoading, setIsAdHocLaunchLoading] = useState(false); const [associateError, setAssociateError] = useState(null); const [disassociateError, setDisassociateError] = useState(null); - const { id: inventoryId, groupId } = useParams(); + const { id: inventoryId, groupId, inventoryType } = useParams(); const location = useLocation(); const { @@ -69,9 +69,10 @@ function InventoryRelatedGroupList() { searchableKeys: getSearchableKeys(actions.data.actions?.GET), canAdd: actions.data.actions && - Object.prototype.hasOwnProperty.call(actions.data.actions, 'POST'), + Object.prototype.hasOwnProperty.call(actions.data.actions, 'POST') && + inventoryType !== 'constructed_inventory', }; - }, [groupId, location.search, inventoryId]), + }, [groupId, location.search, inventoryType, inventoryId]), { groups: [], itemCount: 0, diff --git a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroups.js b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroups.js index d5904062b3..bca8ffc26a 100644 --- a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroups.js +++ b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroups.js @@ -8,13 +8,13 @@ function InventoryRelatedGroups() {