mirror of
https://github.com/ansible/awx.git
synced 2026-01-22 06:58:06 -03:30
Add inventory host list related groups column
This commit is contained in:
parent
fd2a8b8531
commit
f0481d0a60
@ -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/`);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<Tr id={`host-row-${host.id}`} ouiaId={`inventory-host-row-${host.id}`}>
|
||||
<Td
|
||||
data-cy={labelId}
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
/>
|
||||
<TdBreakWord id={labelId} dataLabel={t`Name`}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{host.name}</b>
|
||||
</Link>
|
||||
</TdBreakWord>
|
||||
<TdBreakWord
|
||||
id={`host-description-${host.id}`}
|
||||
dataLabel={t`Description`}
|
||||
>
|
||||
{host.description}
|
||||
</TdBreakWord>
|
||||
<ActionsTd dataLabel={t`Actions`} gridColumns="auto 40px">
|
||||
<HostToggle host={host} />
|
||||
<ActionItem
|
||||
visible={host.summary_fields.user_capabilities?.edit}
|
||||
tooltip={t`Edit host`}
|
||||
<>
|
||||
<Tr id={`host-row-${host.id}`} ouiaId={`inventory-host-row-${host.id}`}>
|
||||
<Td
|
||||
data-cy={labelId}
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
/>
|
||||
<TdBreakWord id={labelId} dataLabel={t`Name`}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{host.name}</b>
|
||||
</Link>
|
||||
</TdBreakWord>
|
||||
<TdBreakWord
|
||||
id={`host-description-${host.id}`}
|
||||
dataLabel={t`Description`}
|
||||
>
|
||||
<Button
|
||||
ouiaId={`${host.id}-edit-button`}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`${editUrl}`}
|
||||
{host.description}
|
||||
</TdBreakWord>
|
||||
<TdBreakWord
|
||||
id={`host-related-groups-${host.id}`}
|
||||
dataLabel={t`Related Groups`}
|
||||
>
|
||||
<ChipGroup
|
||||
aria-label={t`Related Groups`}
|
||||
numChips={4}
|
||||
totalChips={initialGroups.count}
|
||||
ouiaId="host-related-groups-chips"
|
||||
onOverflowChipClick={() => handleOverflowChipClick(host.id)}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
{relatedGroups.map((group) => (
|
||||
<Chip key={group.name} isReadOnly>
|
||||
{group.name}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
</TdBreakWord>
|
||||
<ActionsTd
|
||||
aria-label={t`Actions`}
|
||||
dataLabel={t`Actions`}
|
||||
gridColumns="auto 40px"
|
||||
>
|
||||
<HostToggle host={host} />
|
||||
<ActionItem
|
||||
visible={host.summary_fields.user_capabilities?.edit}
|
||||
tooltip={t`Edit host`}
|
||||
>
|
||||
<Button
|
||||
aria-label={t`Edit host`}
|
||||
ouiaId={`${host.id}-edit-button`}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`${editUrl}`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
{dismissableError && (
|
||||
<AlertModal
|
||||
isOpen={dismissableError}
|
||||
onClose={dismissError}
|
||||
title={t`Error!`}
|
||||
variant="error"
|
||||
>
|
||||
{t`Failed to load related groups.`}
|
||||
<ErrorDetail error={dismissableError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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('<InventoryHostItem />', () => {
|
||||
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) => (
|
||||
<Router history={history}>
|
||||
<table>
|
||||
<tbody>
|
||||
<InventoryHostItem
|
||||
isSelected={false}
|
||||
detailUrl="/host/1"
|
||||
onSelect={() => {}}
|
||||
editUrl={`/inventories/inventory/1/hosts/1/edit`}
|
||||
host={mockHost}
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
{...props}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
</Router>
|
||||
);
|
||||
|
||||
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(<Component />);
|
||||
|
||||
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(
|
||||
<table>
|
||||
<tbody>
|
||||
<InventoryHostItem
|
||||
isSelected={false}
|
||||
detailUrl="/host/1"
|
||||
onSelect={() => {}}
|
||||
host={copyMockHost}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
||||
|
||||
render(<Component host={copyMockHost} />);
|
||||
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(<Component host={copyMockHost} />);
|
||||
|
||||
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(<Component host={copyMockHost} />);
|
||||
await waitFor(() => {
|
||||
const overflowGroupsButton = screen.queryByText('2 more');
|
||||
fireEvent.click(overflowGroupsButton);
|
||||
});
|
||||
expect(screen.getByRole('dialog', { name: 'Alert modal Error!' }));
|
||||
});
|
||||
});
|
||||
|
||||
@ -137,6 +137,7 @@ function InventoryHostList() {
|
||||
<HeaderRow qsConfig={QS_CONFIG}>
|
||||
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
|
||||
<HeaderCell sortKey="description">{t`Description`}</HeaderCell>
|
||||
<HeaderCell>{t`Related Groups`}</HeaderCell>
|
||||
<HeaderCell>{t`Actions`}</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user