Merge pull request #13209 from marshmalien/5990-related-group-column

Add inventory host list related groups column
This commit is contained in:
Sarah Akus
2022-12-02 16:23:09 -05:00
committed by GitHub
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 }); 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/`);
} }

View File

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

View File

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

View File

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