mirror of
https://github.com/ansible/awx.git
synced 2026-02-15 18:20:00 -03:30
Merge pull request #13209 from marshmalien/5990-related-group-column
Add inventory host list related groups column
This commit is contained in:
@@ -20,6 +20,10 @@ class Hosts extends Base {
|
|||||||
return this.http.get(`${this.baseUrl}${id}/all_groups/`, { params });
|
return this.http.get(`${this.baseUrl}${id}/all_groups/`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readGroups(id, params) {
|
||||||
|
return this.http.get(`${this.baseUrl}${id}/groups/`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
readGroupsOptions(id) {
|
readGroupsOptions(id) {
|
||||||
return this.http.options(`${this.baseUrl}${id}/groups/`);
|
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 { string, bool, func } from 'prop-types';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Button } from '@patternfly/react-core';
|
|
||||||
import { Tr, Td } from '@patternfly/react-table';
|
import { Tr, Td } from '@patternfly/react-table';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
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 HostToggle from 'components/HostToggle';
|
||||||
|
import { ActionsTd, ActionItem, TdBreakWord } from 'components/PaginatedTable';
|
||||||
|
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||||
import { Host } from 'types';
|
import { Host } from 'types';
|
||||||
|
|
||||||
function InventoryHostItem({
|
function InventoryHostItem({
|
||||||
@@ -19,45 +23,106 @@ function InventoryHostItem({
|
|||||||
rowIndex,
|
rowIndex,
|
||||||
}) {
|
}) {
|
||||||
const labelId = `check-action-${host.id}`;
|
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 (
|
return (
|
||||||
<Tr id={`host-row-${host.id}`} ouiaId={`inventory-host-row-${host.id}`}>
|
<>
|
||||||
<Td
|
<Tr id={`host-row-${host.id}`} ouiaId={`inventory-host-row-${host.id}`}>
|
||||||
data-cy={labelId}
|
<Td
|
||||||
select={{
|
data-cy={labelId}
|
||||||
rowIndex,
|
select={{
|
||||||
isSelected,
|
rowIndex,
|
||||||
onSelect,
|
isSelected,
|
||||||
}}
|
onSelect,
|
||||||
/>
|
}}
|
||||||
<TdBreakWord id={labelId} dataLabel={t`Name`}>
|
/>
|
||||||
<Link to={`${detailUrl}`}>
|
<TdBreakWord id={labelId} dataLabel={t`Name`}>
|
||||||
<b>{host.name}</b>
|
<Link to={`${detailUrl}`}>
|
||||||
</Link>
|
<b>{host.name}</b>
|
||||||
</TdBreakWord>
|
</Link>
|
||||||
<TdBreakWord
|
</TdBreakWord>
|
||||||
id={`host-description-${host.id}`}
|
<TdBreakWord
|
||||||
dataLabel={t`Description`}
|
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`}
|
|
||||||
>
|
>
|
||||||
<Button
|
{host.description}
|
||||||
ouiaId={`${host.id}-edit-button`}
|
</TdBreakWord>
|
||||||
variant="plain"
|
<TdBreakWord
|
||||||
component={Link}
|
id={`host-related-groups-${host.id}`}
|
||||||
to={`${editUrl}`}
|
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 />
|
{relatedGroups.map((group) => (
|
||||||
</Button>
|
<Chip key={group.name} isReadOnly>
|
||||||
</ActionItem>
|
{group.name}
|
||||||
</ActionsTd>
|
</Chip>
|
||||||
</Tr>
|
))}
|
||||||
|
</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 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 InventoryHostItem from './InventoryHostItem';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import english from '../../../locales/en/messages';
|
||||||
|
|
||||||
|
jest.mock('api');
|
||||||
|
|
||||||
const mockHost = {
|
const mockHost = {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -24,58 +39,194 @@ const mockHost = {
|
|||||||
finished: '2020-02-26T22:38:41.037991Z',
|
finished: '2020-02-26T22:38:41.037991Z',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
groups: {
|
||||||
|
count: 1,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
name: 'group_11',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('<InventoryHostItem />', () => {
|
describe('<InventoryHostItem />', () => {
|
||||||
let wrapper;
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/inventories/inventory/1/hosts'],
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
const getChips = (currentScreen) => {
|
||||||
wrapper = mountWithContexts(
|
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>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<InventoryHostItem
|
<InventoryHostItem
|
||||||
isSelected={false}
|
|
||||||
detailUrl="/host/1"
|
detailUrl="/host/1"
|
||||||
onSelect={() => {}}
|
editUrl={`/inventories/inventory/1/hosts/1/edit`}
|
||||||
host={mockHost}
|
host={mockHost}
|
||||||
|
isSelected={false}
|
||||||
|
onSelect={() => {}}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
);
|
</Router>
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.loadLocaleData({ en: { plurals: en } });
|
||||||
|
i18n.load({ en: english });
|
||||||
|
i18n.activate('en');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display expected details', () => {
|
test('should display expected details', () => {
|
||||||
expect(wrapper.find('InventoryHostItem').length).toBe(1);
|
render(<Component />);
|
||||||
expect(wrapper.find('Td[dataLabel="Name"]').find('Link').prop('to')).toBe(
|
|
||||||
|
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'
|
'/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', () => {
|
const relatedGroupChips = getChips(screen);
|
||||||
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
expect(relatedGroupChips).toEqual(['group_11']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('edit button hidden from users without edit capabilities', () => {
|
test('edit button hidden from users without edit capabilities', () => {
|
||||||
const copyMockHost = { ...mockHost };
|
const copyMockHost = { ...mockHost };
|
||||||
copyMockHost.summary_fields.user_capabilities.edit = false;
|
copyMockHost.summary_fields.user_capabilities.edit = false;
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<table>
|
render(<Component host={copyMockHost} />);
|
||||||
<tbody>
|
expect(screen.queryByText('Edit host')).toBeNull();
|
||||||
<InventoryHostItem
|
|
||||||
isSelected={false}
|
|
||||||
detailUrl="/host/1"
|
|
||||||
onSelect={() => {}}
|
|
||||||
host={copyMockHost}
|
|
||||||
/>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display host toggle', () => {
|
test('should show and hide related groups on overflow button click', async () => {
|
||||||
expect(wrapper.find('HostToggle').length).toBe(1);
|
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}>
|
<HeaderRow qsConfig={QS_CONFIG}>
|
||||||
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
|
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
|
||||||
<HeaderCell sortKey="description">{t`Description`}</HeaderCell>
|
<HeaderCell sortKey="description">{t`Description`}</HeaderCell>
|
||||||
|
<HeaderCell>{t`Related Groups`}</HeaderCell>
|
||||||
<HeaderCell>{t`Actions`}</HeaderCell>
|
<HeaderCell>{t`Actions`}</HeaderCell>
|
||||||
</HeaderRow>
|
</HeaderRow>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user