diff --git a/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx b/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx
index 64e60c9f21..66bbbc634a 100644
--- a/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx
+++ b/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx
@@ -116,6 +116,7 @@ class SelectResourceStep extends React.Component {
name={item[displayKey]}
label={item[displayKey]}
onSelect={() => onRowClick(item)}
+ onDeselect={() => onRowClick(item)}
/>
)}
renderToolbar={props => }
diff --git a/awx/ui_next/src/components/RoutedTabs/RoutedTabs.jsx b/awx/ui_next/src/components/RoutedTabs/RoutedTabs.jsx
index 964806f05e..1cc90f3995 100644
--- a/awx/ui_next/src/components/RoutedTabs/RoutedTabs.jsx
+++ b/awx/ui_next/src/components/RoutedTabs/RoutedTabs.jsx
@@ -1,9 +1,8 @@
import React from 'react';
-import { shape, string, number, arrayOf } from 'prop-types';
+import { shape, string, number, arrayOf, node, oneOfType } from 'prop-types';
import { Tab, Tabs as PFTabs } from '@patternfly/react-core';
import { withRouter } from 'react-router-dom';
import styled from 'styled-components';
-import { CaretLeftIcon } from '@patternfly/react-icons';
const Tabs = styled(PFTabs)`
--pf-c-tabs__button--PaddingLeft: 20px;
@@ -63,15 +62,7 @@ function RoutedTabs(props) {
eventKey={tab.id}
key={tab.id}
link={tab.link}
- title={
- tab.isNestedTabs ? (
- <>
- {tab.name}
- >
- ) : (
- tab.name
- )
- }
+ title={tab.name}
/>
))}
@@ -87,7 +78,7 @@ RoutedTabs.propTypes = {
shape({
id: number.isRequired,
link: string.isRequired,
- name: string.isRequired,
+ name: oneOfType([string.isRequired, node.isRequired]),
})
).isRequired,
};
diff --git a/awx/ui_next/src/screens/Host/Host.jsx b/awx/ui_next/src/screens/Host/Host.jsx
index 7fc96f9ea5..203ec74212 100644
--- a/awx/ui_next/src/screens/Host/Host.jsx
+++ b/awx/ui_next/src/screens/Host/Host.jsx
@@ -2,13 +2,17 @@ import React, { Component } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Switch, Route, withRouter, Redirect, Link } from 'react-router-dom';
-import { Card, PageSection } from '@patternfly/react-core';
+import { Card } from '@patternfly/react-core';
+import { CaretLeftIcon } from '@patternfly/react-icons';
+
import { TabbedCardHeader } from '@components/Card';
import CardCloseButton from '@components/CardCloseButton';
import RoutedTabs from '@components/RoutedTabs';
import ContentError from '@components/ContentError';
+import ContentLoading from '@components/ContentLoading';
import HostFacts from './HostFacts';
import HostDetail from './HostDetail';
+
import HostEdit from './HostEdit';
import HostGroups from './HostGroups';
import HostCompletedJobs from './HostCompletedJobs';
@@ -46,14 +50,20 @@ class Host extends Component {
}
async loadHost() {
- const { match, setBreadcrumb } = this.props;
- const id = parseInt(match.params.id, 10);
+ const { match, setBreadcrumb, history, inventory } = this.props;
this.setState({ contentError: null, hasContentLoading: true });
try {
- const { data } = await HostsAPI.readDetail(id);
- setBreadcrumb(data);
+ const { data } = await HostsAPI.readDetail(
+ match.params.hostId || match.params.id
+ );
this.setState({ host: data });
+
+ if (history.location.pathname.startsWith('/hosts')) {
+ setBreadcrumb(data);
+ } else {
+ setBreadcrumb(inventory, data);
+ }
} catch (err) {
this.setState({ contentError: err });
} finally {
@@ -63,20 +73,41 @@ class Host extends Component {
render() {
const { location, match, history, i18n } = this.props;
-
- const { host, contentError, hasContentLoading, isInitialized } = this.state;
-
+ const { host, hasContentLoading, isInitialized, contentError } = this.state;
const tabsArray = [
- { name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
- { name: i18n._(t`Facts`), link: `${match.url}/facts`, id: 1 },
- { name: i18n._(t`Groups`), link: `${match.url}/groups`, id: 2 },
+ {
+ name: i18n._(t`Details`),
+ link: `${match.url}/details`,
+ id: 0,
+ },
+ {
+ name: i18n._(t`Facts`),
+ link: `${match.url}/facts`,
+ id: 1,
+ },
+ {
+ name: i18n._(t`Groups`),
+ link: `${match.url}/groups`,
+ id: 2,
+ },
{
name: i18n._(t`Completed Jobs`),
link: `${match.url}/completed_jobs`,
id: 3,
},
];
-
+ if (!history.location.pathname.startsWith('/hosts')) {
+ tabsArray.unshift({
+ name: (
+ <>
+
+ {i18n._(t`Back to Hosts`)}
+ >
+ ),
+ link: `/inventories/inventory/${match.params.id}/hosts`,
+ id: 99,
+ });
+ }
let cardHeader = (
-
-
- {contentError.response.status === 404 && (
-
- {i18n._(`Host not found.`)}{' '}
- {i18n._(`View all Hosts.`)}
-
- )}
-
-
-
- );
+ if (hasContentLoading) {
+ return ;
}
- return (
-
+ if (!hasContentLoading && contentError) {
+ return (
- {cardHeader}
-
-
- {host && (
- }
- />
+
+ {contentError.response.status === 404 && (
+
+ {i18n._(`Host not found.`)}{' '}
+ {i18n._(`View all Hosts.`)}
+
)}
- {host && (
- }
- />
- )}
- {host && (
- }
- />
- )}
- {host && (
- }
- />
- )}
- {host && (
- }
- />
- )}
-
- !hasContentLoading && (
-
- {match.params.id && (
-
- {i18n._(`View Host Details`)}
-
- )}
-
- )
- }
- />
- ,
-
+
-
+ );
+ }
+ const redirect = location.pathname.startsWith('/hosts') ? (
+
+ ) : (
+
+ );
+ return (
+
+ {cardHeader}
+
+ {redirect}
+ {host && (
+ (
+ this.setState({ host: newHost })}
+ />
+ )}
+ />
+ )}
+ ))
+ {host && (
+ }
+ />
+ )}
+ {host && (
+ }
+ />
+ )}
+ {host && (
+ }
+ />
+ )}
+ {host && (
+ }
+ />
+ )}
+
+ !hasContentLoading && (
+
+ {match.params.id && (
+
+ {i18n._(`View Host Details`)}
+
+ )}
+
+ )
+ }
+ />
+ ,
+
+
);
}
}
diff --git a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx
index db62e11f60..8f402ead06 100644
--- a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx
+++ b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx
@@ -1,64 +1,167 @@
-import React from 'react';
-import { Link } from 'react-router-dom';
+import React, { useState } from 'react';
+import { Link, useHistory, useParams, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Host } from '@types';
import { Button } from '@patternfly/react-core';
import { CardBody, CardActionsRow } from '@components/Card';
+import AlertModal from '@components/AlertModal';
+import ErrorDetail from '@components/ErrorDetail';
import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
import { VariablesDetail } from '@components/CodeMirrorInput';
+import { Sparkline } from '@components/Sparkline';
+import DeleteButton from '@components/DeleteButton';
+import Switch from '@components/Switch';
+import { HostsAPI } from '@api';
-function HostDetail({ host, i18n }) {
- const { created, description, id, modified, name, summary_fields } = host;
+function HostDetail({ host, i18n, onUpdateHost }) {
+ const {
+ created,
+ description,
+ id,
+ modified,
+ name,
+ enabled,
+ summary_fields: {
+ inventory,
+ recent_jobs,
+ kind,
+ created_by,
+ modified_by,
+ user_capabilities,
+ },
+ } = host;
+ const history = useHistory();
+ const { pathname } = useLocation();
+ const { id: inventoryId, hostId: inventoryHostId } = useParams();
+ const [isLoading, setIsloading] = useState(false);
+ const [deletionError, setDeletionError] = useState(false);
+ const [toggleLoading, setToggleLoading] = useState(false);
+ const [toggleError, setToggleError] = useState(false);
+
+ const handleHostToggle = async () => {
+ setToggleLoading(true);
+ try {
+ const { data } = await HostsAPI.update(id, {
+ enabled: !enabled,
+ });
+ onUpdateHost(data);
+ } catch (err) {
+ setToggleError(err);
+ } finally {
+ setToggleLoading(false);
+ }
+ };
+
+ const handleHostDelete = async () => {
+ setIsloading(true);
+ try {
+ await HostsAPI.destroy(id);
+ setIsloading(false);
+ history.push(`/inventories/inventory/${inventoryId}/hosts`);
+ } catch (err) {
+ setDeletionError(err);
+ }
+ };
+
+ if (toggleError && !toggleLoading) {
+ return (
+ setToggleError(false)}
+ >
+ {i18n._(t`Failed to toggle host.`)}
+
+
+ );
+ }
+ if (!isLoading && deletionError) {
+ return (
+ setDeletionError(false)}
+ >
+ {i18n._(t`Failed to delete ${name}.`)}
+
+
+ );
+ }
return (
+ handleHostToggle()}
+ aria-label={i18n._(t`Toggle Host`)}
+ />
+ }
+ label={i18n._(t`Activity`)}
+ />
- {summary_fields.inventory && (
+ {inventory && (
- {summary_fields.inventory.name}
+ {inventory.name}
}
/>
)}
- {summary_fields.user_capabilities &&
- summary_fields.user_capabilities.edit && (
-
- )}
+ {user_capabilities && user_capabilities.edit && (
+
+ )}
+ {user_capabilities && user_capabilities.delete && (
+ handleHostDelete()}
+ modalTitle={i18n._(t`Delete Host`)}
+ name={host.name}
+ />
+ )}
);
diff --git a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx
index 749c512171..5562a5cc05 100644
--- a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx
+++ b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx
@@ -50,8 +50,7 @@ describe('', () => {
test('should show edit button for users with edit permission', async () => {
const wrapper = mountWithContexts();
- // VariablesDetail has two buttons
- const editButton = wrapper.find('Button').at(2);
+ const editButton = wrapper.find('Button[aria-label="edit"]');
expect(editButton.text()).toEqual('Edit');
expect(editButton.prop('to')).toBe('/hosts/1/edit');
});
@@ -61,7 +60,6 @@ describe('', () => {
readOnlyHost.summary_fields.user_capabilities.edit = false;
const wrapper = mountWithContexts();
await waitForElement(wrapper, 'HostDetail');
- // VariablesDetail has two buttons
- expect(wrapper.find('Button').length).toBe(2);
+ expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0);
});
});
diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.jsx
index 4231ef2cc0..36ddd8f0dc 100644
--- a/awx/ui_next/src/screens/Host/HostList/HostList.jsx
+++ b/awx/ui_next/src/screens/Host/HostList/HostList.jsx
@@ -2,7 +2,7 @@ import React, { Component, Fragment } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import { Card, PageSection } from '@patternfly/react-core';
+import { Card } from '@patternfly/react-core';
import { HostsAPI } from '@api';
import AlertModal from '@components/AlertModal';
@@ -180,76 +180,74 @@ class HostsList extends Component {
return (
-
-
- (
- ,
- canAdd ? (
-
- ) : null,
- ]}
- />
- )}
- renderItem={o => (
- row.id === o.id)}
- onSelect={() => this.handleSelect(o)}
- toggleHost={this.handleHostToggle}
- toggleLoading={toggleLoading === o.id}
- />
- )}
- emptyStateControls={
- canAdd ? (
-
- ) : null
- }
- />
-
-
+
+ (
+ ,
+ canAdd ? (
+
+ ) : null,
+ ]}
+ />
+ )}
+ renderItem={o => (
+ row.id === o.id)}
+ onSelect={() => this.handleSelect(o)}
+ onToggleHost={this.handleHostToggle}
+ toggleLoading={toggleLoading === o.id}
+ />
+ )}
+ emptyStateControls={
+ canAdd ? (
+
+ ) : null
+ }
+ />
+
{toggleError && !toggleLoading && (
toggleHost(host)}
+ onChange={() => onToggleHost(host)}
aria-label={i18n._(t`Toggle host`)}
/>
diff --git a/awx/ui_next/src/screens/Host/HostList/HostListItem.test.jsx b/awx/ui_next/src/screens/Host/HostList/HostListItem.test.jsx
index d9e5987661..285f71fba4 100644
--- a/awx/ui_next/src/screens/Host/HostList/HostListItem.test.jsx
+++ b/awx/ui_next/src/screens/Host/HostList/HostListItem.test.jsx
@@ -4,7 +4,7 @@ import { mountWithContexts } from '@testUtils/enzymeHelpers';
import HostsListItem from './HostListItem';
-let toggleHost;
+let onToggleHost;
const mockHost = {
id: 1,
@@ -24,7 +24,7 @@ const mockHost = {
describe('', () => {
beforeEach(() => {
- toggleHost = jest.fn();
+ onToggleHost = jest.fn();
});
afterEach(() => {
@@ -38,7 +38,7 @@ describe('', () => {
detailUrl="/host/1"
onSelect={() => {}}
host={mockHost}
- toggleHost={toggleHost}
+ onToggleHost={onToggleHost}
/>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
@@ -52,7 +52,7 @@ describe('', () => {
detailUrl="/host/1"
onSelect={() => {}}
host={copyMockHost}
- toggleHost={toggleHost}
+ onToggleHost={onToggleHost}
/>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
@@ -64,7 +64,7 @@ describe('', () => {
detailUrl="/host/1"
onSelect={() => {}}
host={mockHost}
- toggleHost={toggleHost}
+ onToggleHost={onToggleHost}
/>
);
wrapper
@@ -72,7 +72,7 @@ describe('', () => {
.first()
.find('input')
.simulate('change');
- expect(toggleHost).toHaveBeenCalledWith(mockHost);
+ expect(onToggleHost).toHaveBeenCalledWith(mockHost);
});
test('handles toggle click when host is disabled', () => {
@@ -82,7 +82,7 @@ describe('', () => {
detailUrl="/host/1"
onSelect={() => {}}
host={mockHost}
- toggleHost={toggleHost}
+ onToggleHost={onToggleHost}
/>
);
wrapper
@@ -90,6 +90,6 @@ describe('', () => {
.first()
.find('input')
.simulate('change');
- expect(toggleHost).toHaveBeenCalledWith(mockHost);
+ expect(onToggleHost).toHaveBeenCalledWith(mockHost);
});
});
diff --git a/awx/ui_next/src/screens/Host/Hosts.jsx b/awx/ui_next/src/screens/Host/Hosts.jsx
index 0a17916223..1ea3e92905 100644
--- a/awx/ui_next/src/screens/Host/Hosts.jsx
+++ b/awx/ui_next/src/screens/Host/Hosts.jsx
@@ -2,6 +2,7 @@ import React, { Component, Fragment } from 'react';
import { Route, withRouter, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
+import { PageSection } from '@patternfly/react-core';
import { Config } from '@contexts/Config';
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
@@ -46,31 +47,31 @@ class Hosts extends Component {
};
render() {
- const { match, history, location } = this.props;
+ const { match } = this.props;
const { breadcrumbConfig } = this.state;
return (
-
- } />
- (
-
- {({ me }) => (
-
- )}
-
- )}
- />
- } />
-
+
+
+ } />
+ (
+
+ {({ me }) => (
+
+ )}
+
+ )}
+ />
+ } />
+
+
);
}
diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx
index 4253078486..90ce307706 100644
--- a/awx/ui_next/src/screens/Inventory/Inventories.jsx
+++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx
@@ -27,7 +27,7 @@ class Inventories extends Component {
};
}
- setBreadCrumbConfig = (inventory, group) => {
+ setBreadCrumbConfig = (inventory, nestedResource) => {
const { i18n } = this.props;
if (!inventory) {
return;
@@ -39,33 +39,49 @@ class Inventories extends Component {
'/inventories/inventory/add': i18n._(t`Create New Inventory`),
'/inventories/smart_inventory/add': i18n._(t`Create New Smart Inventory`),
[`/inventories/${inventoryKind}/${inventory.id}`]: `${inventory.name}`,
- [`/inventories/${inventoryKind}/${inventory.id}/details`]: i18n._(
- t`Details`
- ),
- [`/inventories/${inventoryKind}/${inventory.id}/edit`]: i18n._(
- t`Edit Details`
- ),
+
[`/inventories/${inventoryKind}/${inventory.id}/access`]: i18n._(
t`Access`
),
[`/inventories/${inventoryKind}/${inventory.id}/completed_jobs`]: i18n._(
t`Completed Jobs`
),
+ [`/inventories/${inventoryKind}/${inventory.id}/details`]: i18n._(
+ t`Details`
+ ),
+ [`/inventories/${inventoryKind}/${inventory.id}/edit`]: i18n._(
+ t`Edit Details`
+ ),
+ [`/inventories/${inventoryKind}/${inventory.id}/groups`]: i18n._(
+ t`Groups`
+ ),
[`/inventories/${inventoryKind}/${inventory.id}/hosts`]: i18n._(t`Hosts`),
+
+ [`/inventories/${inventoryKind}/${inventory.id}/sources`]: i18n._(
+ t`Sources`
+ ),
+
[`/inventories/${inventoryKind}/${inventory.id}/hosts/add`]: i18n._(
t`Create New Host`
),
- [`/inventories/inventory/${inventory.id}/sources`]: i18n._(t`Sources`),
- [`/inventories/inventory/${inventory.id}/groups`]: i18n._(t`Groups`),
- [`/inventories/inventory/${inventory.id}/groups/add`]: i18n._(
+ [`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource &&
+ nestedResource.id}/edit`]: i18n._(t`Edit Details`),
+ [`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource &&
+ nestedResource.id}/details`]: i18n._(t`Host Details`),
+ [`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource &&
+ nestedResource.id}`]: i18n._(
+ t`${nestedResource && nestedResource.name}`
+ ),
+
+ [`/inventories/${inventoryKind}/${inventory.id}/groups/add`]: i18n._(
t`Create New Group`
),
- [`/inventories/inventory/${inventory.id}/groups/${group &&
- group.id}`]: `${group && group.name}`,
- [`/inventories/inventory/${inventory.id}/groups/${group &&
- group.id}/details`]: i18n._(t`Group Details`),
- [`/inventories/inventory/${inventory.id}/groups/${group &&
- group.id}/edit`]: i18n._(t`Edit Details`),
+ [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource &&
+ nestedResource.id}/edit`]: i18n._(t`Edit Details`),
+ [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource &&
+ nestedResource.id}/details`]: i18n._(t`Group Details`),
+ [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource &&
+ nestedResource.id}`]: `${nestedResource && nestedResource.name}`,
};
this.setState({ breadcrumbConfig });
};
diff --git a/awx/ui_next/src/screens/Inventory/Inventory.jsx b/awx/ui_next/src/screens/Inventory/Inventory.jsx
index e632610660..edf8e088fc 100644
--- a/awx/ui_next/src/screens/Inventory/Inventory.jsx
+++ b/awx/ui_next/src/screens/Inventory/Inventory.jsx
@@ -8,14 +8,15 @@ import CardCloseButton from '@components/CardCloseButton';
import ContentError from '@components/ContentError';
import RoutedTabs from '@components/RoutedTabs';
import { ResourceAccessList } from '@components/ResourceAccessList';
+import ContentLoading from '@components/ContentLoading';
import InventoryDetail from './InventoryDetail';
-import InventoryHosts from './InventoryHosts';
-import InventoryHostAdd from './InventoryHostAdd';
+
import InventoryGroups from './InventoryGroups';
import InventoryCompletedJobs from './InventoryCompletedJobs';
import InventorySources from './InventorySources';
import { InventoriesAPI } from '@api';
import InventoryEdit from './InventoryEdit';
+import InventoryHosts from './InventoryHosts/InventoryHosts';
function Inventory({ history, i18n, location, match, setBreadcrumb }) {
const [contentError, setContentError] = useState(null);
@@ -61,10 +62,14 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
if (
location.pathname.endsWith('edit') ||
location.pathname.endsWith('add') ||
- location.pathname.includes('groups/')
+ location.pathname.includes('groups/') ||
+ location.pathname.includes('hosts/')
) {
cardHeader = null;
}
+ if (hasContentLoading) {
+ return ;
+ }
if (!hasContentLoading && contentError) {
return (
@@ -111,9 +116,16 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
render={() => }
/>,
}
+ key="hosts"
+ path="/inventories/inventory/:id/hosts"
+ render={() => (
+
+ )}
/>,
)}
/>,
- }
- />,
+
+ {i18n._(t`Back to Groups`)}
+ >
+ ),
link: `/inventories/inventory/${inventory.id}/groups`,
id: 99,
- isNestedTabs: true,
},
{
name: i18n._(t`Details`),
@@ -65,9 +70,10 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) {
},
];
- // In cases where a user manipulates the url such that they try to navigate to a Inventory Group
- // that is not associated with the Inventory Id in the Url this Content Error is thrown.
- // Inventory Groups have a 1: 1 relationship to Inventories thus their Ids must corrolate.
+ // In cases where a user manipulates the url such that they try to navigate to a
+ // Inventory Group that is not associated with the Inventory Id in the Url this
+ // Content Error is thrown. Inventory Groups have a 1:1 relationship to Inventories
+ // thus their Ids must corrolate.
if (contentLoading) {
return ;
diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx
index 585d18bc51..9b5afe74bd 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx
@@ -62,10 +62,10 @@ describe('', () => {
test('renders successfully', async () => {
expect(wrapper.length).toBe(1);
});
- test('expect all tabs to exist, including Return to Groups', async () => {
- expect(wrapper.find('button[aria-label="Return to Groups"]').length).toBe(
- 1
- );
+ test('expect all tabs to exist, including Back to Groups', async () => {
+ expect(
+ wrapper.find('button[link="/inventories/inventory/1/groups"]').length
+ ).toBe(1);
expect(wrapper.find('button[aria-label="Details"]').length).toBe(1);
expect(wrapper.find('button[aria-label="Related Groups"]').length).toBe(1);
expect(wrapper.find('button[aria-label="Hosts"]').length).toBe(1);
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx
new file mode 100644
index 0000000000..df4d7783fd
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.jsx
@@ -0,0 +1,226 @@
+import React, { useEffect, useState } from 'react';
+import { withRouter } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { getQSConfig, parseQueryString } from '@util/qs';
+import { InventoriesAPI, HostsAPI } from '@api';
+
+import AlertModal from '@components/AlertModal';
+import DataListToolbar from '@components/DataListToolbar';
+import ErrorDetail from '@components/ErrorDetail';
+import PaginatedDataList, {
+ ToolbarAddButton,
+ ToolbarDeleteButton,
+} from '@components/PaginatedDataList';
+import InventoryHostItem from './InventoryHostItem';
+
+const QS_CONFIG = getQSConfig('host', {
+ page: 1,
+ page_size: 20,
+ order_by: 'name',
+});
+
+function InventoryHostList({ i18n, location, match }) {
+ const [actions, setActions] = useState(null);
+ const [contentError, setContentError] = useState(null);
+ const [deletionError, setDeletionError] = useState(null);
+ const [hostCount, setHostCount] = useState(0);
+ const [hosts, setHosts] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [selected, setSelected] = useState([]);
+ const [toggleError, setToggleError] = useState(null);
+ const [toggleLoading, setToggleLoading] = useState(null);
+
+ const fetchHosts = (id, queryString) => {
+ const params = parseQueryString(QS_CONFIG, queryString);
+ return InventoriesAPI.readHosts(id, params);
+ };
+
+ useEffect(() => {
+ async function fetchData() {
+ try {
+ const [
+ {
+ data: { count, results },
+ },
+ {
+ data: { actions: optionActions },
+ },
+ ] = await Promise.all([
+ fetchHosts(match.params.id, location.search),
+ InventoriesAPI.readOptions(),
+ ]);
+
+ setHosts(results);
+ setHostCount(count);
+ setActions(optionActions);
+ } catch (error) {
+ setContentError(error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ fetchData();
+ }, [match.params.id, location]);
+
+ const handleSelectAll = isSelected => {
+ setSelected(isSelected ? [...hosts] : []);
+ };
+
+ const handleSelect = row => {
+ if (selected.some(s => s.id === row.id)) {
+ setSelected(selected.filter(s => s.id !== row.id));
+ } else {
+ setSelected(selected.concat(row));
+ }
+ };
+
+ const handleDelete = async () => {
+ setIsLoading(true);
+
+ try {
+ await Promise.all(selected.map(host => HostsAPI.destroy(host.id)));
+ } catch (error) {
+ setDeletionError(error);
+ } finally {
+ setSelected([]);
+ try {
+ const {
+ data: { count, results },
+ } = await fetchHosts(match.params.id, location.search);
+
+ setHosts(results);
+ setHostCount(count);
+ } catch (error) {
+ setContentError(error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ const handleToggle = async hostToToggle => {
+ setToggleLoading(hostToToggle.id);
+
+ try {
+ const { data: updatedHost } = await HostsAPI.update(hostToToggle.id, {
+ enabled: !hostToToggle.enabled,
+ });
+
+ setHosts(
+ hosts.map(host => (host.id === updatedHost.id ? updatedHost : host))
+ );
+ } catch (error) {
+ setToggleError(error);
+ } finally {
+ setToggleLoading(null);
+ }
+ };
+
+ const canAdd =
+ actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
+ const isAllSelected = selected.length > 0 && selected.length === hosts.length;
+
+ return (
+ <>
+ (
+ ,
+ canAdd && (
+
+ ),
+ ]}
+ />
+ )}
+ renderItem={o => (
+ row.id === o.id)}
+ onSelect={() => handleSelect(o)}
+ toggleHost={handleToggle}
+ toggleLoading={toggleLoading === o.id}
+ />
+ )}
+ emptyStateControls={
+ canAdd && (
+
+ )
+ }
+ />
+
+ {toggleError && !toggleLoading && (
+ setToggleError(false)}
+ >
+ {i18n._(t`Failed to toggle host.`)}
+
+
+ )}
+
+ {deletionError && (
+ setDeletionError(null)}
+ >
+ {i18n._(t`Failed to delete one or more hosts.`)}
+
+
+ )}
+ >
+ );
+}
+
+export default withI18n()(withRouter(InventoryHostList));
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.test.jsx
similarity index 94%
rename from awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.test.jsx
rename to awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.test.jsx
index 715413c81b..d35d4da489 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostList.test.jsx
@@ -2,7 +2,7 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import { InventoriesAPI, HostsAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
-import InventoryHosts from './InventoryHosts';
+import InventoryHostList from './InventoryHostList';
import mockInventory from '../shared/data.inventory.json';
jest.mock('@api');
@@ -62,7 +62,7 @@ const mockHosts = [
},
];
-describe('', () => {
+describe('', () => {
let wrapper;
beforeEach(async () => {
@@ -81,7 +81,7 @@ describe('', () => {
},
});
await act(async () => {
- wrapper = mountWithContexts();
+ wrapper = mountWithContexts();
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
@@ -91,7 +91,7 @@ describe('', () => {
});
test('initially renders successfully', () => {
- expect(wrapper.find('InventoryHosts').length).toBe(1);
+ expect(wrapper.find('InventoryHostList').length).toBe(1);
});
test('should fetch hosts from api and render them in the list', async () => {
@@ -261,7 +261,9 @@ describe('', () => {
},
});
await act(async () => {
- wrapper = mountWithContexts();
+ wrapper = mountWithContexts(
+
+ );
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
@@ -272,7 +274,9 @@ describe('', () => {
Promise.reject(new Error())
);
await act(async () => {
- wrapper = mountWithContexts();
+ wrapper = mountWithContexts(
+
+ );
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx
index 9e96793e3f..cbb0b4d6d3 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx
@@ -1,228 +1,46 @@
-import React, { useEffect, useState } from 'react';
-import { withRouter } from 'react-router-dom';
-import { withI18n } from '@lingui/react';
-import { t } from '@lingui/macro';
-import { getQSConfig, parseQueryString } from '@util/qs';
-import { InventoriesAPI, HostsAPI } from '@api';
+import React from 'react';
+import { Switch, Route, withRouter } from 'react-router-dom';
-import AlertModal from '@components/AlertModal';
-import DataListToolbar from '@components/DataListToolbar';
-import ErrorDetail from '@components/ErrorDetail';
-import PaginatedDataList, {
- ToolbarAddButton,
- ToolbarDeleteButton,
-} from '@components/PaginatedDataList';
-import InventoryHostItem from './InventoryHostItem';
-
-const QS_CONFIG = getQSConfig('host', {
- page: 1,
- page_size: 20,
- order_by: 'name',
-});
-
-function InventoryHosts({ i18n, location, match }) {
- const [actions, setActions] = useState(null);
- const [contentError, setContentError] = useState(null);
- const [deletionError, setDeletionError] = useState(null);
- const [hostCount, setHostCount] = useState(0);
- const [hosts, setHosts] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
- const [selected, setSelected] = useState([]);
- const [toggleError, setToggleError] = useState(null);
- const [toggleLoading, setToggleLoading] = useState(null);
-
- const fetchHosts = (id, queryString) => {
- const params = parseQueryString(QS_CONFIG, queryString);
- return InventoriesAPI.readHosts(id, params);
- };
-
- useEffect(() => {
- async function fetchData() {
- try {
- const [
- {
- data: { count, results },
- },
- {
- data: { actions: optionActions },
- },
- ] = await Promise.all([
- fetchHosts(match.params.id, location.search),
- InventoriesAPI.readOptions(),
- ]);
-
- setHosts(results);
- setHostCount(count);
- setActions(optionActions);
- } catch (error) {
- setContentError(error);
- } finally {
- setIsLoading(false);
- }
- }
-
- fetchData();
- }, [match.params.id, location]);
-
- const handleSelectAll = isSelected => {
- setSelected(isSelected ? [...hosts] : []);
- };
-
- const handleSelect = row => {
- if (selected.some(s => s.id === row.id)) {
- setSelected(selected.filter(s => s.id !== row.id));
- } else {
- setSelected(selected.concat(row));
- }
- };
-
- const handleDelete = async () => {
- setIsLoading(true);
-
- try {
- await Promise.all(selected.map(host => HostsAPI.destroy(host.id)));
- } catch (error) {
- setDeletionError(error);
- } finally {
- setSelected([]);
- try {
- const {
- data: { count, results },
- } = await fetchHosts(match.params.id, location.search);
-
- setHosts(results);
- setHostCount(count);
- } catch (error) {
- setContentError(error);
- } finally {
- setIsLoading(false);
- }
- }
- };
-
- const handleToggle = async hostToToggle => {
- setToggleLoading(hostToToggle.id);
-
- try {
- const { data: updatedHost } = await HostsAPI.update(hostToToggle.id, {
- enabled: !hostToToggle.enabled,
- });
-
- setHosts(
- hosts.map(host => (host.id === updatedHost.id ? updatedHost : host))
- );
- } catch (error) {
- setToggleError(error);
- } finally {
- setToggleLoading(null);
- }
- };
-
- const canAdd =
- actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
- const isAllSelected = selected.length > 0 && selected.length === hosts.length;
+import Host from '../../Host/Host';
+import InventoryHostList from './InventoryHostList';
+import InventoryHostAdd from '../InventoryHostAdd';
+function InventoryHosts({ match, setBreadcrumb, i18n, inventory }) {
return (
- <>
- (
- ,
- canAdd && (
-
- ),
- ]}
- />
- )}
- renderItem={o => (
- row.id === o.id)}
- onSelect={() => handleSelect(o)}
- toggleHost={handleToggle}
- toggleLoading={toggleLoading === o.id}
- />
- )}
- emptyStateControls={
- canAdd && (
-
- )
- }
+
+ }
/>
-
- {toggleError && !toggleLoading && (
- setToggleError(false)}
- >
- {i18n._(t`Failed to toggle host.`)}
-
-
- )}
-
- {deletionError && (
- setDeletionError(null)}
- >
- {i18n._(t`Failed to delete one or more hosts.`)}
-
-
- )}
- >
+ ,
+ (
+
+ )}
+ />
+ ,
+ (
+
+ )}
+ />
+ ,
+
);
}
-export default withI18n()(withRouter(InventoryHosts));
+export default withRouter(InventoryHosts);
diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/index.js b/awx/ui_next/src/screens/Inventory/InventoryHosts/index.js
index 6d33814f29..0cb4fe95bc 100644
--- a/awx/ui_next/src/screens/Inventory/InventoryHosts/index.js
+++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/index.js
@@ -1 +1 @@
-export { default } from './InventoryHosts';
+export { default } from './InventoryHostList';