Add inventory host list related groups column

This commit is contained in:
Marliana Lara 2022-11-11 13:15:05 -05:00
parent fd2a8b8531
commit f0481d0a60
No known key found for this signature in database
GPG Key ID: B4C0959216CD8E5D
4 changed files with 288 additions and 67 deletions

View File

@ -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/`);
}

View File

@ -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>
)}
</>
);
}

View File

@ -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!' }));
});
});

View File

@ -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>
}