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';