Adds Related Groups List

This commit is contained in:
Alex Corey 2020-09-29 09:19:03 -04:00
parent 717861fb46
commit 9620da287c
12 changed files with 763 additions and 17 deletions

View File

@ -107,7 +107,11 @@ function DisassociateButton({
>
{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 => (
<span key={item.id}>

View File

@ -17,6 +17,7 @@ import ContentLoading from '../../../components/ContentLoading';
import InventoryGroupEdit from '../InventoryGroupEdit/InventoryGroupEdit';
import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail';
import InventoryGroupHosts from '../InventoryGroupHosts';
import InventoryGroupsRelatedGroup from '../InventoryRelatedGroups';
import { GroupsAPI } from '../../../api';
@ -129,6 +130,12 @@ function InventoryGroup({ i18n, setBreadcrumb, inventory }) {
>
<InventoryGroupHosts inventoryGroup={inventoryGroup} />
</Route>,
<Route
key="relatedGroups"
path="/inventories/inventory/:id/groups/:groupId/nested_groups"
>
<InventoryGroupsRelatedGroup inventoryGroup={inventoryGroup} />
</Route>,
]}
<Route key="not-found" path="*">
<ContentError>

View File

@ -25,7 +25,7 @@ import DisassociateButton from '../../../components/DisassociateButton';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import InventoryGroupHostListItem from './InventoryGroupHostListItem';
import AddHostDropdown from './AddHostDropdown';
import AddHostDropdown from '../shared/AddDropdown';
const QS_CONFIG = getQSConfig('host', {
page: 1,
@ -216,6 +216,9 @@ function InventoryGroupHostList({ i18n }) {
key="associate"
onAddExisting={() => setIsModalOpen(true)}
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"
onAddExisting={() => setIsModalOpen(true)}
onAddNew={() => history.push(addFormUrl)}
newTitle={i18n._(t`Add new host`)}
existingTitle={i18n._(t`Add existing host`)}
label={i18n._(t`host`)}
/>
)
}

View File

@ -131,7 +131,7 @@ describe('<InventoryGroupHostList />', () => {
});
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({
data: {
actions: {
@ -143,7 +143,7 @@ describe('<InventoryGroupHostList />', () => {
wrapper = mountWithContexts(<InventoryGroupHostList />);
});
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 () => {

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export { default } from './InventoryRelatedGroupList';

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { func } from 'prop-types';
import { func, string } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
@ -9,25 +9,32 @@ import {
DropdownToggle,
} from '@patternfly/react-core';
function AddHostDropdown({ i18n, onAddNew, onAddExisting }) {
function AddDropdown({
i18n,
onAddNew,
onAddExisting,
newTitle,
existingTitle,
label,
}) {
const [isOpen, setIsOpen] = useState(false);
const dropdownItems = [
<DropdownItem
key="add-new"
aria-label="add new host"
aria-label={`add new ${label}`}
component="button"
onClick={onAddNew}
>
{i18n._(t`Add New Host`)}
{newTitle}
</DropdownItem>,
<DropdownItem
key="add-existing"
aria-label="add existing host"
aria-label={`add existing ${label}`}
component="button"
onClick={onAddExisting}
>
{i18n._(t`Add Existing Host`)}
{existingTitle}
</DropdownItem>,
];
@ -37,8 +44,8 @@ function AddHostDropdown({ i18n, onAddNew, onAddExisting }) {
position={DropdownPosition.right}
toggle={
<DropdownToggle
id="add-host-dropdown"
aria-label="add host"
id={`add-${label}-dropdown`}
aria-label={`add ${label}`}
isPrimary
onToggle={() => setIsOpen(prevState => !prevState)}
>
@ -50,9 +57,12 @@ function AddHostDropdown({ i18n, onAddNew, onAddExisting }) {
);
}
AddHostDropdown.propTypes = {
AddDropdown.propTypes = {
onAddNew: func.isRequired,
onAddExisting: func.isRequired,
newTitle: string.isRequired,
existingTitle: string.isRequired,
label: string.isRequired,
};
export default withI18n()(AddHostDropdown);
export default withI18n()(AddDropdown);

View File

@ -1,8 +1,8 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import AddHostDropdown from './AddHostDropdown';
import AddDropdown from './AddDropdown';
describe('<AddHostDropdown />', () => {
describe('<AddDropdown />', () => {
let wrapper;
let dropdownToggle;
const onAddNew = jest.fn();
@ -10,7 +10,7 @@ describe('<AddHostDropdown />', () => {
beforeEach(() => {
wrapper = mountWithContexts(
<AddHostDropdown onAddNew={onAddNew} onAddExisting={onAddExisting} />
<AddDropdown onAddNew={onAddNew} onAddExisting={onAddExisting} />
);
dropdownToggle = wrapper.find('DropdownToggle button');
});

View 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": ""
}
]
}