diff --git a/awx/ui/src/api/models/Hosts.js b/awx/ui/src/api/models/Hosts.js index 5fa8cee698..0422407da9 100644 --- a/awx/ui/src/api/models/Hosts.js +++ b/awx/ui/src/api/models/Hosts.js @@ -20,6 +20,10 @@ class Hosts extends Base { return this.http.get(`${this.baseUrl}${id}/all_groups/`, { params }); } + readGroups(id, params) { + return this.http.get(`${this.baseUrl}${id}/groups/`, { params }); + } + readGroupsOptions(id) { return this.http.options(`${this.baseUrl}${id}/groups/`); } diff --git a/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostItem.js b/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostItem.js index 9998dc12a4..3b84a80562 100644 --- a/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostItem.js +++ b/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostItem.js @@ -1,13 +1,17 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { string, 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 { PencilAltIcon } from '@patternfly/react-icons'; -import { ActionsTd, ActionItem, TdBreakWord } from 'components/PaginatedTable'; - +import { Button, Chip } from '@patternfly/react-core'; +import { HostsAPI } from 'api'; +import AlertModal from 'components/AlertModal'; +import ChipGroup from 'components/ChipGroup'; +import ErrorDetail from 'components/ErrorDetail'; import HostToggle from 'components/HostToggle'; +import { ActionsTd, ActionItem, TdBreakWord } from 'components/PaginatedTable'; +import useRequest, { useDismissableError } from 'hooks/useRequest'; import { Host } from 'types'; function InventoryHostItem({ @@ -19,45 +23,106 @@ function InventoryHostItem({ rowIndex, }) { const labelId = `check-action-${host.id}`; + const initialGroups = host?.summary_fields?.groups ?? { + results: [], + count: 0, + }; + + const { + error, + request: fetchRelatedGroups, + result: relatedGroups, + } = useRequest( + useCallback(async (hostId) => { + const { data } = await HostsAPI.readGroups(hostId); + return data.results; + }, []), + initialGroups.results + ); + + const { error: dismissableError, dismissError } = useDismissableError(error); + + const handleOverflowChipClick = (hostId) => { + if (relatedGroups.length === initialGroups.count) { + return; + } + fetchRelatedGroups(hostId); + }; return ( - - - - - {host.name} - - - - {host.description} - - - - + + + + + {host.name} + + + - - - - + {relatedGroups.map((group) => ( + + {group.name} + + ))} + + + + + + + + + + {dismissableError && ( + + {t`Failed to load related groups.`} + + + )} + ); } diff --git a/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostItem.test.js b/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostItem.test.js index a343264d53..86ebf42ccc 100644 --- a/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostItem.test.js +++ b/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostItem.test.js @@ -1,6 +1,21 @@ import React from 'react'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { Router } from 'react-router-dom'; +import { + render, + fireEvent, + screen, + waitFor, + within, +} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { HostsAPI } from 'api'; +import { i18n } from '@lingui/core'; +import { en } from 'make-plural/plurals'; import InventoryHostItem from './InventoryHostItem'; +import { createMemoryHistory } from 'history'; +import english from '../../../locales/en/messages'; + +jest.mock('api'); const mockHost = { id: 1, @@ -24,58 +39,194 @@ const mockHost = { finished: '2020-02-26T22:38:41.037991Z', }, ], + groups: { + count: 1, + results: [ + { + id: 11, + name: 'group_11', + }, + ], + }, }, }; describe('', () => { - let wrapper; + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/hosts'], + }); - beforeEach(() => { - wrapper = mountWithContexts( + const getChips = (currentScreen) => { + const list = currentScreen.getByRole('list', { + name: 'Related Groups', + }); + const { getAllByRole } = within(list); + const items = getAllByRole('listitem'); + return items.map((item) => item.textContent); + }; + + const Component = (props) => ( + {}} + editUrl={`/inventories/inventory/1/hosts/1/edit`} host={mockHost} + isSelected={false} + onSelect={() => {}} + {...props} />
- ); +
+ ); + + beforeEach(() => { + i18n.loadLocaleData({ en: { plurals: en } }); + i18n.load({ en: english }); + i18n.activate('en'); }); test('should display expected details', () => { - expect(wrapper.find('InventoryHostItem').length).toBe(1); - expect(wrapper.find('Td[dataLabel="Name"]').find('Link').prop('to')).toBe( + render(); + + expect(screen.getByRole('cell', { name: 'Bar' })).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: 'Toggle host' }) + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Host 1' })).toHaveAttribute( + 'href', '/host/1' ); - expect(wrapper.find('Td[dataLabel="Description"]').text()).toBe('Bar'); - }); + expect(screen.getByRole('link', { name: 'Edit host' })).toHaveAttribute( + 'href', + '/inventories/inventory/1/hosts/1/edit' + ); - test('edit button shown to users with edit capabilities', () => { - expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); + const relatedGroupChips = getChips(screen); + expect(relatedGroupChips).toEqual(['group_11']); }); test('edit button hidden from users without edit capabilities', () => { const copyMockHost = { ...mockHost }; copyMockHost.summary_fields.user_capabilities.edit = false; - wrapper = mountWithContexts( - - - {}} - host={copyMockHost} - /> - -
- ); - expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + + render(); + expect(screen.queryByText('Edit host')).toBeNull(); }); - test('should display host toggle', () => { - expect(wrapper.find('HostToggle').length).toBe(1); + test('should show and hide related groups on overflow button click', async () => { + const copyMockHost = { ...mockHost }; + const mockGroups = [ + { + id: 1, + name: 'group_1', + }, + { + id: 2, + name: 'group_2', + }, + { + id: 3, + name: 'group_3', + }, + { + id: 4, + name: 'group_4', + }, + { + id: 5, + name: 'group_5', + }, + { + id: 6, + name: 'group_6', + }, + ]; + copyMockHost.summary_fields.groups = { + count: 6, + results: mockGroups.slice(0, 5), + }; + HostsAPI.readGroups.mockReturnValue({ + data: { + results: mockGroups, + }, + }); + + render(); + + const initialRelatedGroupChips = getChips(screen); + expect(initialRelatedGroupChips).toEqual([ + 'group_1', + 'group_2', + 'group_3', + 'group_4', + '2 more', + ]); + + const overflowGroupsButton = screen.queryByText('2 more'); + fireEvent.click(overflowGroupsButton); + + await waitFor(() => expect(HostsAPI.readGroups).toHaveBeenCalledWith(1)); + + const expandedRelatedGroupChips = getChips(screen); + expect(expandedRelatedGroupChips).toEqual([ + 'group_1', + 'group_2', + 'group_3', + 'group_4', + 'group_5', + 'group_6', + 'Show less', + ]); + + const collapseGroupsButton = await screen.findByText('Show less'); + fireEvent.click(collapseGroupsButton); + + const collapsedRelatedGroupChips = getChips(screen); + expect(collapsedRelatedGroupChips).toEqual(initialRelatedGroupChips); + }); + + test('should show error modal when related groups api request fails', async () => { + const copyMockHost = { ...mockHost }; + const mockGroups = [ + { + id: 1, + name: 'group_1', + }, + { + id: 2, + name: 'group_2', + }, + { + id: 3, + name: 'group_3', + }, + { + id: 4, + name: 'group_4', + }, + { + id: 5, + name: 'group_5', + }, + { + id: 6, + name: 'group_6', + }, + ]; + copyMockHost.summary_fields.groups = { + count: 6, + results: mockGroups.slice(0, 5), + }; + HostsAPI.readGroups.mockRejectedValueOnce(new Error()); + + render(); + await waitFor(() => { + const overflowGroupsButton = screen.queryByText('2 more'); + fireEvent.click(overflowGroupsButton); + }); + expect(screen.getByRole('dialog', { name: 'Alert modal Error!' })); }); }); diff --git a/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostList.js b/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostList.js index 3b8bbe5fe0..b1a3d6e720 100644 --- a/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostList.js +++ b/awx/ui/src/screens/Inventory/InventoryHosts/InventoryHostList.js @@ -137,6 +137,7 @@ function InventoryHostList() { {t`Name`} {t`Description`} + {t`Related Groups`} {t`Actions`} }