From 9620da287c3e3477798446fa4ff575cdb50803be Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 29 Sep 2020 09:19:03 -0400 Subject: [PATCH] Adds Related Groups List --- .../DisassociateButton/DisassociateButton.jsx | 6 +- .../InventoryGroup/InventoryGroup.jsx | 7 + .../InventoryGroupHostList.jsx | 8 +- .../InventoryGroupHostList.test.jsx | 4 +- .../InventoryRelatedGroupList.jsx | 246 ++++++++++++++++++ .../InventoryRelatedGroupList.test.jsx | 148 +++++++++++ .../InventoryRelatedGroupListItem.jsx | 90 +++++++ .../InventoryRelatedGroupListItem.test.jsx | 53 ++++ .../Inventory/InventoryRelatedGroups/index.js | 1 + .../AddDropdown.jsx} | 30 ++- .../AddDropdown.test.jsx} | 6 +- .../Inventory/shared/data.relatedGroups.json | 181 +++++++++++++ 12 files changed, 763 insertions(+), 17 deletions(-) create mode 100644 awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/index.js rename awx/ui_next/src/screens/Inventory/{InventoryGroupHosts/AddHostDropdown.jsx => shared/AddDropdown.jsx} (65%) rename awx/ui_next/src/screens/Inventory/{InventoryGroupHosts/AddHostDropdown.test.jsx => shared/AddDropdown.test.jsx} (83%) create mode 100644 awx/ui_next/src/screens/Inventory/shared/data.relatedGroups.json diff --git a/awx/ui_next/src/components/DisassociateButton/DisassociateButton.jsx b/awx/ui_next/src/components/DisassociateButton/DisassociateButton.jsx index 4252788cf0..cb6a5cc518 100644 --- a/awx/ui_next/src/components/DisassociateButton/DisassociateButton.jsx +++ b/awx/ui_next/src/components/DisassociateButton/DisassociateButton.jsx @@ -107,7 +107,11 @@ function DisassociateButton({ > {modalNote && {modalNote}} -
{i18n._(t`This action will disassociate the following:`)}
+
+ {i18n._( + t`This action will disassociate the following and any of their descendents:` + )} +
{itemsToDisassociate.map(item => ( diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx index 9818bd43de..f9fcdb9fa7 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx @@ -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 }) { > , + + + , ]} diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx index 22ee9b9338..228a1fb9cf 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx @@ -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`)} /> ) } diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx index 59550792a5..729cd7017a 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx @@ -131,7 +131,7 @@ describe('', () => { }); 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('', () => { wrapper = mountWithContexts(); }); 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 () => { diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.jsx b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.jsx new file mode 100644 index 0000000000..0ea82d41c2 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.jsx @@ -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 ( + <> + ( + + setSelected(isSelected ? [...groups] : []) + } + qsConfig={QS_CONFIG} + additionalControls={[ + ...(canAdd + ? [ + setIsModalOpen(true)} + onAddNew={() => history.push(addFormUrl)} + newTitle={i18n._(t`Add new group`)} + existingTitle={i18n._(t`Add existing group`)} + label={i18n._(t`group`)} + />, + ] + : []), + + {({ isKebabified }) => + isKebabified ? ( + + {({ openAdHocCommands, isDisabled }) => ( + + {i18n._(t`Run command`)} + + )} + + ) : ( + + + + {({ openAdHocCommands, isDisabled }) => ( + + )} + + + + ) + } + , + {}} + itemsToDisassociate={selected} + modalTitle={i18n._(t`Disassociate related group(s)?`)} + />, + ]} + /> + )} + renderItem={o => ( + row.id === o.id)} + onSelect={() => handleSelect(o)} + /> + )} + emptyStateControls={ + canAdd && ( + setIsModalOpen(true)} + onAddNew={() => history.push(addFormUrl)} + newTitle={i18n._(t`Add new group`)} + existingTitle={i18n._(t`Add existing group`)} + label={i18n._(t`group`)} + /> + ) + } + /> + {isModalOpen && ( + {}} + onClose={() => setIsModalOpen(false)} + title={i18n._(t`Select Groups`)} + /> + )} + + ); +} +export default withI18n()(InventoryRelatedGroupList); diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.jsx new file mode 100644 index 0000000000..834e0738ec --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.jsx @@ -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('', () => { + 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(); + }); + 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(); + }); + 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(); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('AddDropdown').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.jsx new file mode 100644 index 0000000000..67f543c9a7 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.jsx @@ -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 ( + + + + + + {group.name} + + , + ]} + /> + + {group.summary_fields.user_capabilities?.edit && ( + + + + )} + + + + ); +} + +InventoryRelatedGroupListItem.propTypes = { + detailUrl: string.isRequired, + editUrl: string.isRequired, + group: Group.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(InventoryRelatedGroupListItem); diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.test.jsx new file mode 100644 index 0000000000..f52eaad866 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.test.jsx @@ -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('', () => { + let wrapper; + const mockGroup = mockRelatedGroups.results[0]; + + beforeEach(() => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + + 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( + {}} + /> + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/index.js b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/index.js new file mode 100644 index 0000000000..09833dbe98 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryRelatedGroups/index.js @@ -0,0 +1 @@ +export { default } from './InventoryRelatedGroupList'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.jsx b/awx/ui_next/src/screens/Inventory/shared/AddDropdown.jsx similarity index 65% rename from awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.jsx rename to awx/ui_next/src/screens/Inventory/shared/AddDropdown.jsx index e5393aa50d..86a2704954 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/AddDropdown.jsx @@ -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 = [ - {i18n._(t`Add New Host`)} + {newTitle} , - {i18n._(t`Add Existing Host`)} + {existingTitle} , ]; @@ -37,8 +44,8 @@ function AddHostDropdown({ i18n, onAddNew, onAddExisting }) { position={DropdownPosition.right} toggle={ 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); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.test.jsx b/awx/ui_next/src/screens/Inventory/shared/AddDropdown.test.jsx similarity index 83% rename from awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.test.jsx rename to awx/ui_next/src/screens/Inventory/shared/AddDropdown.test.jsx index 8a7d2c11d4..68a8ab7ef9 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/AddHostDropdown.test.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/AddDropdown.test.jsx @@ -1,8 +1,8 @@ import React from 'react'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import AddHostDropdown from './AddHostDropdown'; +import AddDropdown from './AddDropdown'; -describe('', () => { +describe('', () => { let wrapper; let dropdownToggle; const onAddNew = jest.fn(); @@ -10,7 +10,7 @@ describe('', () => { beforeEach(() => { wrapper = mountWithContexts( - + ); dropdownToggle = wrapper.find('DropdownToggle button'); }); diff --git a/awx/ui_next/src/screens/Inventory/shared/data.relatedGroups.json b/awx/ui_next/src/screens/Inventory/shared/data.relatedGroups.json new file mode 100644 index 0000000000..835ab95d79 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/data.relatedGroups.json @@ -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": "" + } + ] +}