mirror of
https://github.com/ansible/awx.git
synced 2026-02-19 04:00:06 -03:30
Adds Related Groups List
This commit is contained in:
@@ -107,7 +107,11 @@ function DisassociateButton({
|
|||||||
>
|
>
|
||||||
{modalNote && <ModalNote>{modalNote}</ModalNote>}
|
{modalNote && <ModalNote>{modalNote}</ModalNote>}
|
||||||
|
|
||||||
<div>{i18n._(t`This action will disassociate the following:`)}</div>
|
<div>
|
||||||
|
{i18n._(
|
||||||
|
t`This action will disassociate the following and any of their descendents:`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{itemsToDisassociate.map(item => (
|
{itemsToDisassociate.map(item => (
|
||||||
<span key={item.id}>
|
<span key={item.id}>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import ContentLoading from '../../../components/ContentLoading';
|
|||||||
import InventoryGroupEdit from '../InventoryGroupEdit/InventoryGroupEdit';
|
import InventoryGroupEdit from '../InventoryGroupEdit/InventoryGroupEdit';
|
||||||
import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail';
|
import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail';
|
||||||
import InventoryGroupHosts from '../InventoryGroupHosts';
|
import InventoryGroupHosts from '../InventoryGroupHosts';
|
||||||
|
import InventoryGroupsRelatedGroup from '../InventoryRelatedGroups';
|
||||||
|
|
||||||
import { GroupsAPI } from '../../../api';
|
import { GroupsAPI } from '../../../api';
|
||||||
|
|
||||||
@@ -129,6 +130,12 @@ function InventoryGroup({ i18n, setBreadcrumb, inventory }) {
|
|||||||
>
|
>
|
||||||
<InventoryGroupHosts inventoryGroup={inventoryGroup} />
|
<InventoryGroupHosts inventoryGroup={inventoryGroup} />
|
||||||
</Route>,
|
</Route>,
|
||||||
|
<Route
|
||||||
|
key="relatedGroups"
|
||||||
|
path="/inventories/inventory/:id/groups/:groupId/nested_groups"
|
||||||
|
>
|
||||||
|
<InventoryGroupsRelatedGroup inventoryGroup={inventoryGroup} />
|
||||||
|
</Route>,
|
||||||
]}
|
]}
|
||||||
<Route key="not-found" path="*">
|
<Route key="not-found" path="*">
|
||||||
<ContentError>
|
<ContentError>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import DisassociateButton from '../../../components/DisassociateButton';
|
|||||||
import { Kebabified } from '../../../contexts/Kebabified';
|
import { Kebabified } from '../../../contexts/Kebabified';
|
||||||
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
|
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
|
||||||
import InventoryGroupHostListItem from './InventoryGroupHostListItem';
|
import InventoryGroupHostListItem from './InventoryGroupHostListItem';
|
||||||
import AddHostDropdown from './AddHostDropdown';
|
import AddHostDropdown from '../shared/AddDropdown';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('host', {
|
const QS_CONFIG = getQSConfig('host', {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -216,6 +216,9 @@ function InventoryGroupHostList({ i18n }) {
|
|||||||
key="associate"
|
key="associate"
|
||||||
onAddExisting={() => setIsModalOpen(true)}
|
onAddExisting={() => setIsModalOpen(true)}
|
||||||
onAddNew={() => history.push(addFormUrl)}
|
onAddNew={() => history.push(addFormUrl)}
|
||||||
|
newTitle={i18n._(t`Add new host`)}
|
||||||
|
existingTitle={i18n._(t`Add existing host`)}
|
||||||
|
label={i18n._(t`host`)}
|
||||||
/>,
|
/>,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -283,6 +286,9 @@ function InventoryGroupHostList({ i18n }) {
|
|||||||
key="associate"
|
key="associate"
|
||||||
onAddExisting={() => setIsModalOpen(true)}
|
onAddExisting={() => setIsModalOpen(true)}
|
||||||
onAddNew={() => history.push(addFormUrl)}
|
onAddNew={() => history.push(addFormUrl)}
|
||||||
|
newTitle={i18n._(t`Add new host`)}
|
||||||
|
existingTitle={i18n._(t`Add existing host`)}
|
||||||
|
label={i18n._(t`host`)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ describe('<InventoryGroupHostList />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should show add dropdown button according to permissions', async () => {
|
test('should show add dropdown button according to permissions', async () => {
|
||||||
expect(wrapper.find('AddHostDropdown').length).toBe(1);
|
expect(wrapper.find('AddDropdown').length).toBe(1);
|
||||||
InventoriesAPI.readHostsOptions.mockResolvedValueOnce({
|
InventoriesAPI.readHostsOptions.mockResolvedValueOnce({
|
||||||
data: {
|
data: {
|
||||||
actions: {
|
actions: {
|
||||||
@@ -143,7 +143,7 @@ describe('<InventoryGroupHostList />', () => {
|
|||||||
wrapper = mountWithContexts(<InventoryGroupHostList />);
|
wrapper = mountWithContexts(<InventoryGroupHostList />);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
expect(wrapper.find('AddHostDropdown').length).toBe(0);
|
expect(wrapper.find('AddDropdown').length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('expected api calls are made for multi-delete', async () => {
|
test('expected api calls are made for multi-delete', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Tooltip,
|
||||||
|
DropdownItem,
|
||||||
|
ToolbarItem,
|
||||||
|
} from '@patternfly/react-core';
|
||||||
|
import { useParams, useLocation, useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { GroupsAPI, InventoriesAPI } from '../../../api';
|
||||||
|
import useRequest from '../../../util/useRequest';
|
||||||
|
import { getQSConfig, parseQueryString, mergeParams } from '../../../util/qs';
|
||||||
|
import useSelected from '../../../util/useSelected';
|
||||||
|
|
||||||
|
import DataListToolbar from '../../../components/DataListToolbar';
|
||||||
|
import PaginatedDataList from '../../../components/PaginatedDataList';
|
||||||
|
import InventoryGroupRelatedGroupListItem from './InventoryRelatedGroupListItem';
|
||||||
|
import AddDropdown from '../shared/AddDropdown';
|
||||||
|
import { Kebabified } from '../../../contexts/Kebabified';
|
||||||
|
import AdHocCommandsButton from '../../../components/AdHocCommands/AdHocCommands';
|
||||||
|
import AssociateModal from '../../../components/AssociateModal';
|
||||||
|
import DisassociateButton from '../../../components/DisassociateButton';
|
||||||
|
|
||||||
|
const QS_CONFIG = getQSConfig('group', {
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
order_by: 'name',
|
||||||
|
});
|
||||||
|
function InventoryRelatedGroupList({ i18n, inventoryGroup }) {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const { id: inventoryId, groupId } = useParams();
|
||||||
|
const location = useLocation();
|
||||||
|
const history = useHistory();
|
||||||
|
const {
|
||||||
|
request: fetchRelated,
|
||||||
|
result: {
|
||||||
|
groups,
|
||||||
|
itemCount,
|
||||||
|
relatedSearchableKeys,
|
||||||
|
searchableKeys,
|
||||||
|
canAdd,
|
||||||
|
},
|
||||||
|
isLoading,
|
||||||
|
error: contentError,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const params = parseQueryString(QS_CONFIG, location.search);
|
||||||
|
const [response, actions] = await Promise.all([
|
||||||
|
GroupsAPI.readChildren(groupId, params),
|
||||||
|
InventoriesAPI.readGroupsOptions(inventoryId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups: response.data.results,
|
||||||
|
itemCount: response.data.count,
|
||||||
|
relatedSearchableKeys: (
|
||||||
|
actions?.data?.related_search_fields || []
|
||||||
|
).map(val => val.slice(0, -8)),
|
||||||
|
searchableKeys: Object.keys(actions.data.actions?.GET || {}).filter(
|
||||||
|
key => actions.data.actions?.GET[key].filterable
|
||||||
|
),
|
||||||
|
canAdd:
|
||||||
|
actions.data.actions &&
|
||||||
|
Object.prototype.hasOwnProperty.call(actions.data.actions, 'POST'),
|
||||||
|
};
|
||||||
|
}, [groupId, location.search, inventoryId]),
|
||||||
|
{ groups: [], itemCount: 0, canAdd: false }
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRelated();
|
||||||
|
}, [fetchRelated]);
|
||||||
|
|
||||||
|
const fetchGroupsToAssociate = useCallback(
|
||||||
|
params => {
|
||||||
|
return InventoriesAPI.readGroups(
|
||||||
|
inventoryId,
|
||||||
|
mergeParams(params, { not__id: inventoryId, not__parents: inventoryId })
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[inventoryId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchGroupsOptions = useCallback(
|
||||||
|
() => InventoriesAPI.readGroupsOptions(inventoryId),
|
||||||
|
[inventoryId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
|
||||||
|
groups
|
||||||
|
);
|
||||||
|
|
||||||
|
const addFormUrl = `/home`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PaginatedDataList
|
||||||
|
contentError={contentError}
|
||||||
|
hasContentLoading={isLoading}
|
||||||
|
items={groups}
|
||||||
|
itemCount={itemCount}
|
||||||
|
pluralizedItemName={i18n._(t`Related Groups`)}
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
onRowClick={handleSelect}
|
||||||
|
toolbarSearchColumns={[
|
||||||
|
{
|
||||||
|
name: i18n._(t`Name`),
|
||||||
|
key: 'name__icontains',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Created By (Username)`),
|
||||||
|
key: 'created_by__username__icontains',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Modified By (Username)`),
|
||||||
|
key: 'modified_by__username__icontains',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
toolbarSortColumns={[
|
||||||
|
{
|
||||||
|
name: i18n._(t`Name`),
|
||||||
|
key: 'name',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
toolbarSearchableKeys={searchableKeys}
|
||||||
|
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||||
|
renderToolbar={props => (
|
||||||
|
<DataListToolbar
|
||||||
|
{...props}
|
||||||
|
showSelectAll
|
||||||
|
isAllSelected={isAllSelected}
|
||||||
|
onSelectAll={isSelected =>
|
||||||
|
setSelected(isSelected ? [...groups] : [])
|
||||||
|
}
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
additionalControls={[
|
||||||
|
...(canAdd
|
||||||
|
? [
|
||||||
|
<AddDropdown
|
||||||
|
key="associate"
|
||||||
|
onAddExisting={() => setIsModalOpen(true)}
|
||||||
|
onAddNew={() => history.push(addFormUrl)}
|
||||||
|
newTitle={i18n._(t`Add new group`)}
|
||||||
|
existingTitle={i18n._(t`Add existing group`)}
|
||||||
|
label={i18n._(t`group`)}
|
||||||
|
/>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
<Kebabified>
|
||||||
|
{({ isKebabified }) =>
|
||||||
|
isKebabified ? (
|
||||||
|
<AdHocCommandsButton
|
||||||
|
adHocItems={selected}
|
||||||
|
apiModule={InventoriesAPI}
|
||||||
|
itemId={parseInt(inventoryId, 10)}
|
||||||
|
>
|
||||||
|
{({ openAdHocCommands, isDisabled }) => (
|
||||||
|
<DropdownItem
|
||||||
|
key="run command"
|
||||||
|
onClick={openAdHocCommands}
|
||||||
|
isDisabled={itemCount === 0 || isDisabled}
|
||||||
|
>
|
||||||
|
{i18n._(t`Run command`)}
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
</AdHocCommandsButton>
|
||||||
|
) : (
|
||||||
|
<ToolbarItem>
|
||||||
|
<Tooltip
|
||||||
|
content={i18n._(
|
||||||
|
t`Select an inventory source by clicking the check box beside it. The inventory source can be a single host or a selection of multiple hosts.`
|
||||||
|
)}
|
||||||
|
position="top"
|
||||||
|
key="adhoc"
|
||||||
|
>
|
||||||
|
<AdHocCommandsButton
|
||||||
|
css="margin-right: 20px"
|
||||||
|
adHocItems={selected}
|
||||||
|
apiModule={InventoriesAPI}
|
||||||
|
itemId={parseInt(inventoryId, 10)}
|
||||||
|
>
|
||||||
|
{({ openAdHocCommands, isDisabled }) => (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
aria-label={i18n._(t`Run command`)}
|
||||||
|
onClick={openAdHocCommands}
|
||||||
|
isDisabled={itemCount === 0 || isDisabled}
|
||||||
|
>
|
||||||
|
{i18n._(t`Run command`)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</AdHocCommandsButton>
|
||||||
|
</Tooltip>
|
||||||
|
</ToolbarItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Kebabified>,
|
||||||
|
<DisassociateButton
|
||||||
|
key="disassociate"
|
||||||
|
onDisassociate={() => {}}
|
||||||
|
itemsToDisassociate={selected}
|
||||||
|
modalTitle={i18n._(t`Disassociate related group(s)?`)}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderItem={o => (
|
||||||
|
<InventoryGroupRelatedGroupListItem
|
||||||
|
key={o.id}
|
||||||
|
group={o}
|
||||||
|
detailUrl={`/inventories/inventory/${inventoryId}/groups/${o.id}/details`}
|
||||||
|
editUrl={`/inventories/inventory/${inventoryId}/groups/${o.id}/edit`}
|
||||||
|
isSelected={selected.some(row => row.id === o.id)}
|
||||||
|
onSelect={() => handleSelect(o)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
emptyStateControls={
|
||||||
|
canAdd && (
|
||||||
|
<AddDropdown
|
||||||
|
key="associate"
|
||||||
|
onAddExisting={() => setIsModalOpen(true)}
|
||||||
|
onAddNew={() => history.push(addFormUrl)}
|
||||||
|
newTitle={i18n._(t`Add new group`)}
|
||||||
|
existingTitle={i18n._(t`Add existing group`)}
|
||||||
|
label={i18n._(t`group`)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{isModalOpen && (
|
||||||
|
<AssociateModal
|
||||||
|
header={i18n._(t`Groups`)}
|
||||||
|
fetchRequest={fetchGroupsToAssociate}
|
||||||
|
optionsRequest={fetchGroupsOptions}
|
||||||
|
isModalOpen={isModalOpen}
|
||||||
|
onAssociate={() => {}}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
title={i18n._(t`Select Groups`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default withI18n()(InventoryRelatedGroupList);
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
import { GroupsAPI, InventoriesAPI } from '../../../api';
|
||||||
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
waitForElement,
|
||||||
|
} from '../../../../testUtils/enzymeHelpers';
|
||||||
|
import InventoryRelatedGroupList from './InventoryRelatedGroupList';
|
||||||
|
import mockRelatedGroups from '../shared/data.relatedGroups.json';
|
||||||
|
|
||||||
|
jest.mock('../../../api/models/Groups');
|
||||||
|
jest.mock('../../../api/models/Inventories');
|
||||||
|
jest.mock('../../../api/models/CredentialTypes');
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useParams: () => ({
|
||||||
|
id: 1,
|
||||||
|
groupId: 2,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('<InventoryRelatedGroupList />', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
GroupsAPI.readChildren.mockResolvedValue({
|
||||||
|
data: { ...mockRelatedGroups },
|
||||||
|
});
|
||||||
|
InventoriesAPI.readGroupsOptions.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
actions: {
|
||||||
|
GET: {},
|
||||||
|
POST: {},
|
||||||
|
},
|
||||||
|
related_search_fields: [
|
||||||
|
'parents__search',
|
||||||
|
'inventory__search',
|
||||||
|
'inventory_sources__search',
|
||||||
|
'created_by__search',
|
||||||
|
'children__search',
|
||||||
|
'modified_by__search',
|
||||||
|
'hosts__search',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<InventoryRelatedGroupList />);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initially renders successfully ', () => {
|
||||||
|
expect(wrapper.find('InventoryRelatedGroupList').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fetch inventory group hosts from api and render them in the list', () => {
|
||||||
|
expect(GroupsAPI.readChildren).toHaveBeenCalled();
|
||||||
|
expect(InventoriesAPI.readGroupsOptions).toHaveBeenCalled();
|
||||||
|
expect(wrapper.find('InventoryRelatedGroupListItem').length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should check and uncheck the row item', async () => {
|
||||||
|
expect(
|
||||||
|
wrapper.find('DataListCheck[id="select-group-2"]').props().checked
|
||||||
|
).toBe(false);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('DataListCheck[id="select-group-2"]').invoke('onChange')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('DataListCheck[id="select-group-2"]').props().checked
|
||||||
|
).toBe(true);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('DataListCheck[id="select-group-2"]').invoke('onChange')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('DataListCheck[id="select-group-2"]').props().checked
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should check all row items when select all is checked', async () => {
|
||||||
|
wrapper.find('DataListCheck').forEach(el => {
|
||||||
|
expect(el.props().checked).toBe(false);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
wrapper.find('DataListCheck').forEach(el => {
|
||||||
|
expect(el.props().checked).toBe(true);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Checkbox#select-all').invoke('onChange')(false);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
wrapper.find('DataListCheck').forEach(el => {
|
||||||
|
expect(el.props().checked).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show content error when api throws error on initial render', async () => {
|
||||||
|
GroupsAPI.readChildren.mockResolvedValueOnce({
|
||||||
|
data: { ...mockRelatedGroups },
|
||||||
|
});
|
||||||
|
InventoriesAPI.readGroupsOptions.mockImplementationOnce(() =>
|
||||||
|
Promise.reject(new Error())
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<InventoryRelatedGroupList />);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show add dropdown button according to permissions', async () => {
|
||||||
|
GroupsAPI.readChildren.mockResolvedValueOnce({
|
||||||
|
data: { ...mockRelatedGroups },
|
||||||
|
});
|
||||||
|
InventoriesAPI.readGroupsOptions.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
actions: {
|
||||||
|
GET: {},
|
||||||
|
},
|
||||||
|
related_search_fields: [
|
||||||
|
'parents__search',
|
||||||
|
'inventory__search',
|
||||||
|
'inventory_sources__search',
|
||||||
|
'created_by__search',
|
||||||
|
'children__search',
|
||||||
|
'modified_by__search',
|
||||||
|
'hosts__search',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<InventoryRelatedGroupList />);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
expect(wrapper.find('AddDropdown').length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import 'styled-components/macro';
|
||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { string, bool, func } from 'prop-types';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DataListAction as _DataListAction,
|
||||||
|
DataListCheck,
|
||||||
|
DataListItem,
|
||||||
|
DataListItemCells,
|
||||||
|
DataListItemRow,
|
||||||
|
Tooltip,
|
||||||
|
} from '@patternfly/react-core';
|
||||||
|
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import DataListCell from '../../../components/DataListCell';
|
||||||
|
|
||||||
|
import { Group } from '../../../types';
|
||||||
|
|
||||||
|
const DataListAction = styled(_DataListAction)`
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
grid-gap: 24px;
|
||||||
|
grid-template-columns: min-content 40px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function InventoryRelatedGroupListItem({
|
||||||
|
i18n,
|
||||||
|
detailUrl,
|
||||||
|
editUrl,
|
||||||
|
group,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}) {
|
||||||
|
const labelId = `check-action-${group.id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataListItem key={group.id} aria-labelledby={labelId} id={`${group.id}`}>
|
||||||
|
<DataListItemRow>
|
||||||
|
<DataListCheck
|
||||||
|
id={`select-group-${group.id}`}
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={onSelect}
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
/>
|
||||||
|
<DataListItemCells
|
||||||
|
dataListCells={[
|
||||||
|
<DataListCell key="name">
|
||||||
|
<Link to={`${detailUrl}`}>
|
||||||
|
<b>{group.name}</b>
|
||||||
|
</Link>
|
||||||
|
</DataListCell>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<DataListAction
|
||||||
|
aria-label="actions"
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
id={labelId}
|
||||||
|
>
|
||||||
|
{group.summary_fields.user_capabilities?.edit && (
|
||||||
|
<Tooltip content={i18n._(t`Edit Group`)} position="top">
|
||||||
|
<Button
|
||||||
|
aria-label={i18n._(t`Edit Group`)}
|
||||||
|
css="grid-column: 2"
|
||||||
|
variant="plain"
|
||||||
|
component={Link}
|
||||||
|
to={`${editUrl}`}
|
||||||
|
>
|
||||||
|
<PencilAltIcon />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</DataListAction>
|
||||||
|
</DataListItemRow>
|
||||||
|
</DataListItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InventoryRelatedGroupListItem.propTypes = {
|
||||||
|
detailUrl: string.isRequired,
|
||||||
|
editUrl: string.isRequired,
|
||||||
|
group: Group.isRequired,
|
||||||
|
isSelected: bool.isRequired,
|
||||||
|
onSelect: func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withI18n()(InventoryRelatedGroupListItem);
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||||
|
import InventoryRelatedGroupListItem from './InventoryRelatedGroupListItem';
|
||||||
|
import mockRelatedGroups from '../shared/data.relatedGroups.json';
|
||||||
|
|
||||||
|
jest.mock('../../../api');
|
||||||
|
|
||||||
|
describe('<InventoryRelatedGroupListItem />', () => {
|
||||||
|
let wrapper;
|
||||||
|
const mockGroup = mockRelatedGroups.results[0];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<InventoryRelatedGroupListItem
|
||||||
|
detailUrl="/group/1"
|
||||||
|
editUrl="/group/1"
|
||||||
|
group={mockGroup}
|
||||||
|
isSelected={false}
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display expected row item content', () => {
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.find('DataListCell')
|
||||||
|
.first()
|
||||||
|
.text()
|
||||||
|
).toBe(' Group 2 Inventory 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edit button shown to users with edit capabilities', () => {
|
||||||
|
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edit button hidden from users without edit capabilities', () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<InventoryRelatedGroupListItem
|
||||||
|
detailUrl="/group/1"
|
||||||
|
editUrl="/group/1"
|
||||||
|
group={mockRelatedGroups.results[2]}
|
||||||
|
isSelected={false}
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './InventoryRelatedGroupList';
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { func } from 'prop-types';
|
import { func, string } from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import {
|
import {
|
||||||
@@ -9,25 +9,32 @@ import {
|
|||||||
DropdownToggle,
|
DropdownToggle,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
|
|
||||||
function AddHostDropdown({ i18n, onAddNew, onAddExisting }) {
|
function AddDropdown({
|
||||||
|
i18n,
|
||||||
|
onAddNew,
|
||||||
|
onAddExisting,
|
||||||
|
newTitle,
|
||||||
|
existingTitle,
|
||||||
|
label,
|
||||||
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const dropdownItems = [
|
const dropdownItems = [
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
key="add-new"
|
key="add-new"
|
||||||
aria-label="add new host"
|
aria-label={`add new ${label}`}
|
||||||
component="button"
|
component="button"
|
||||||
onClick={onAddNew}
|
onClick={onAddNew}
|
||||||
>
|
>
|
||||||
{i18n._(t`Add New Host`)}
|
{newTitle}
|
||||||
</DropdownItem>,
|
</DropdownItem>,
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
key="add-existing"
|
key="add-existing"
|
||||||
aria-label="add existing host"
|
aria-label={`add existing ${label}`}
|
||||||
component="button"
|
component="button"
|
||||||
onClick={onAddExisting}
|
onClick={onAddExisting}
|
||||||
>
|
>
|
||||||
{i18n._(t`Add Existing Host`)}
|
{existingTitle}
|
||||||
</DropdownItem>,
|
</DropdownItem>,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -37,8 +44,8 @@ function AddHostDropdown({ i18n, onAddNew, onAddExisting }) {
|
|||||||
position={DropdownPosition.right}
|
position={DropdownPosition.right}
|
||||||
toggle={
|
toggle={
|
||||||
<DropdownToggle
|
<DropdownToggle
|
||||||
id="add-host-dropdown"
|
id={`add-${label}-dropdown`}
|
||||||
aria-label="add host"
|
aria-label={`add ${label}`}
|
||||||
isPrimary
|
isPrimary
|
||||||
onToggle={() => setIsOpen(prevState => !prevState)}
|
onToggle={() => setIsOpen(prevState => !prevState)}
|
||||||
>
|
>
|
||||||
@@ -50,9 +57,12 @@ function AddHostDropdown({ i18n, onAddNew, onAddExisting }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
AddHostDropdown.propTypes = {
|
AddDropdown.propTypes = {
|
||||||
onAddNew: func.isRequired,
|
onAddNew: func.isRequired,
|
||||||
onAddExisting: func.isRequired,
|
onAddExisting: func.isRequired,
|
||||||
|
newTitle: string.isRequired,
|
||||||
|
existingTitle: string.isRequired,
|
||||||
|
label: string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withI18n()(AddHostDropdown);
|
export default withI18n()(AddDropdown);
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||||
import AddHostDropdown from './AddHostDropdown';
|
import AddDropdown from './AddDropdown';
|
||||||
|
|
||||||
describe('<AddHostDropdown />', () => {
|
describe('<AddDropdown />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
let dropdownToggle;
|
let dropdownToggle;
|
||||||
const onAddNew = jest.fn();
|
const onAddNew = jest.fn();
|
||||||
@@ -10,7 +10,7 @@ describe('<AddHostDropdown />', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<AddHostDropdown onAddNew={onAddNew} onAddExisting={onAddExisting} />
|
<AddDropdown onAddNew={onAddNew} onAddExisting={onAddExisting} />
|
||||||
);
|
);
|
||||||
dropdownToggle = wrapper.find('DropdownToggle button');
|
dropdownToggle = wrapper.find('DropdownToggle button');
|
||||||
});
|
});
|
||||||
181
awx/ui_next/src/screens/Inventory/shared/data.relatedGroups.json
Normal file
181
awx/ui_next/src/screens/Inventory/shared/data.relatedGroups.json
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
{
|
||||||
|
"count": 3,
|
||||||
|
"results": [{
|
||||||
|
"id": 2,
|
||||||
|
"type": "group",
|
||||||
|
"url": "/api/v2/groups/2/",
|
||||||
|
"related": {
|
||||||
|
"created_by": "/api/v2/users/10/",
|
||||||
|
"modified_by": "/api/v2/users/14/",
|
||||||
|
"variable_data": "/api/v2/groups/2/variable_data/",
|
||||||
|
"hosts": "/api/v2/groups/2/hosts/",
|
||||||
|
"potential_children": "/api/v2/groups/2/potential_children/",
|
||||||
|
"children": "/api/v2/groups/2/children/",
|
||||||
|
"all_hosts": "/api/v2/groups/2/all_hosts/",
|
||||||
|
"job_events": "/api/v2/groups/2/job_events/",
|
||||||
|
"job_host_summaries": "/api/v2/groups/2/job_host_summaries/",
|
||||||
|
"activity_stream": "/api/v2/groups/2/activity_stream/",
|
||||||
|
"inventory_sources": "/api/v2/groups/2/inventory_sources/",
|
||||||
|
"ad_hoc_commands": "/api/v2/groups/2/ad_hoc_commands/",
|
||||||
|
"inventory": "/api/v2/inventories/1/"
|
||||||
|
},
|
||||||
|
"summary_fields": {
|
||||||
|
"inventory": {
|
||||||
|
"id": 1,
|
||||||
|
"name": " Inventory 1 Org 0",
|
||||||
|
"description": "",
|
||||||
|
"has_active_failures": false,
|
||||||
|
"total_hosts": 33,
|
||||||
|
"hosts_with_active_failures": 0,
|
||||||
|
"total_groups": 4,
|
||||||
|
"has_inventory_sources": false,
|
||||||
|
"total_inventory_sources": 0,
|
||||||
|
"inventory_sources_with_failures": 0,
|
||||||
|
"organization_id": 1,
|
||||||
|
"kind": ""
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"id": 10,
|
||||||
|
"username": "user-4",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
},
|
||||||
|
"modified_by": {
|
||||||
|
"id": 14,
|
||||||
|
"username": "user-8",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
},
|
||||||
|
"user_capabilities": {
|
||||||
|
"edit": true,
|
||||||
|
"delete": true,
|
||||||
|
"copy": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created": "2020-09-23T14:30:55.263148Z",
|
||||||
|
"modified": "2020-09-23T14:30:55.263175Z",
|
||||||
|
"name": " Group 2 Inventory 0",
|
||||||
|
"description": "",
|
||||||
|
"inventory": 1,
|
||||||
|
"variables": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "group",
|
||||||
|
"url": "/api/v2/groups/3/",
|
||||||
|
"related": {
|
||||||
|
"created_by": "/api/v2/users/11/",
|
||||||
|
"modified_by": "/api/v2/users/15/",
|
||||||
|
"variable_data": "/api/v2/groups/3/variable_data/",
|
||||||
|
"hosts": "/api/v2/groups/3/hosts/",
|
||||||
|
"potential_children": "/api/v2/groups/3/potential_children/",
|
||||||
|
"children": "/api/v2/groups/3/children/",
|
||||||
|
"all_hosts": "/api/v2/groups/3/all_hosts/",
|
||||||
|
"job_events": "/api/v2/groups/3/job_events/",
|
||||||
|
"job_host_summaries": "/api/v2/groups/3/job_host_summaries/",
|
||||||
|
"activity_stream": "/api/v2/groups/3/activity_stream/",
|
||||||
|
"inventory_sources": "/api/v2/groups/3/inventory_sources/",
|
||||||
|
"ad_hoc_commands": "/api/v2/groups/3/ad_hoc_commands/",
|
||||||
|
"inventory": "/api/v2/inventories/1/"
|
||||||
|
},
|
||||||
|
"summary_fields": {
|
||||||
|
"inventory": {
|
||||||
|
"id": 1,
|
||||||
|
"name": " Inventory 1 Org 0",
|
||||||
|
"description": "",
|
||||||
|
"has_active_failures": false,
|
||||||
|
"total_hosts": 33,
|
||||||
|
"hosts_with_active_failures": 0,
|
||||||
|
"total_groups": 4,
|
||||||
|
"has_inventory_sources": false,
|
||||||
|
"total_inventory_sources": 0,
|
||||||
|
"inventory_sources_with_failures": 0,
|
||||||
|
"organization_id": 1,
|
||||||
|
"kind": ""
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"id": 11,
|
||||||
|
"username": "user-5",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
},
|
||||||
|
"modified_by": {
|
||||||
|
"id": 15,
|
||||||
|
"username": "user-9",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
},
|
||||||
|
"user_capabilities": {
|
||||||
|
"edit": true,
|
||||||
|
"delete": true,
|
||||||
|
"copy": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created": "2020-09-23T14:30:55.281583Z",
|
||||||
|
"modified": "2020-09-23T14:30:55.281615Z",
|
||||||
|
"name": " Group 3 Inventory 0",
|
||||||
|
"description": "",
|
||||||
|
"inventory": 1,
|
||||||
|
"variables": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "group",
|
||||||
|
"url": "/api/v2/groups/4/",
|
||||||
|
"related": {
|
||||||
|
"created_by": "/api/v2/users/12/",
|
||||||
|
"modified_by": "/api/v2/users/16/",
|
||||||
|
"variable_data": "/api/v2/groups/4/variable_data/",
|
||||||
|
"hosts": "/api/v2/groups/4/hosts/",
|
||||||
|
"potential_children": "/api/v2/groups/4/potential_children/",
|
||||||
|
"children": "/api/v2/groups/4/children/",
|
||||||
|
"all_hosts": "/api/v2/groups/4/all_hosts/",
|
||||||
|
"job_events": "/api/v2/groups/4/job_events/",
|
||||||
|
"job_host_summaries": "/api/v2/groups/4/job_host_summaries/",
|
||||||
|
"activity_stream": "/api/v2/groups/4/activity_stream/",
|
||||||
|
"inventory_sources": "/api/v2/groups/4/inventory_sources/",
|
||||||
|
"ad_hoc_commands": "/api/v2/groups/4/ad_hoc_commands/",
|
||||||
|
"inventory": "/api/v2/inventories/1/"
|
||||||
|
},
|
||||||
|
"summary_fields": {
|
||||||
|
"inventory": {
|
||||||
|
"id": 1,
|
||||||
|
"name": " Inventory 1 Org 0",
|
||||||
|
"description": "",
|
||||||
|
"has_active_failures": false,
|
||||||
|
"total_hosts": 33,
|
||||||
|
"hosts_with_active_failures": 0,
|
||||||
|
"total_groups": 4,
|
||||||
|
"has_inventory_sources": false,
|
||||||
|
"total_inventory_sources": 0,
|
||||||
|
"inventory_sources_with_failures": 0,
|
||||||
|
"organization_id": 1,
|
||||||
|
"kind": ""
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"id": 12,
|
||||||
|
"username": "user-6",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
},
|
||||||
|
"modified_by": {
|
||||||
|
"id": 16,
|
||||||
|
"username": "user-10",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
},
|
||||||
|
"user_capabilities": {
|
||||||
|
"edit": false,
|
||||||
|
"delete": true,
|
||||||
|
"copy": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created": "2020-09-23T14:30:55.293574Z",
|
||||||
|
"modified": "2020-09-23T14:30:55.293603Z",
|
||||||
|
"name": " Group 4 Inventory 0",
|
||||||
|
"description": "",
|
||||||
|
"inventory": 1,
|
||||||
|
"variables": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user