Merge pull request #5600 from AlexSCorey/5266-InventoryHostDetails

Adds Toggle, Variables, User Link and Delete to Inventory Host/Host Details

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-01-17 15:03:23 +00:00
committed by GitHub
17 changed files with 706 additions and 486 deletions

View File

@@ -116,6 +116,7 @@ class SelectResourceStep extends React.Component {
name={item[displayKey]} name={item[displayKey]}
label={item[displayKey]} label={item[displayKey]}
onSelect={() => onRowClick(item)} onSelect={() => onRowClick(item)}
onDeselect={() => onRowClick(item)}
/> />
)} )}
renderToolbar={props => <DataListToolbar {...props} fillWidth />} renderToolbar={props => <DataListToolbar {...props} fillWidth />}

View File

@@ -1,9 +1,8 @@
import React from 'react'; 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 { Tab, Tabs as PFTabs } from '@patternfly/react-core';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { CaretLeftIcon } from '@patternfly/react-icons';
const Tabs = styled(PFTabs)` const Tabs = styled(PFTabs)`
--pf-c-tabs__button--PaddingLeft: 20px; --pf-c-tabs__button--PaddingLeft: 20px;
@@ -63,15 +62,7 @@ function RoutedTabs(props) {
eventKey={tab.id} eventKey={tab.id}
key={tab.id} key={tab.id}
link={tab.link} link={tab.link}
title={ title={tab.name}
tab.isNestedTabs ? (
<>
<CaretLeftIcon /> {tab.name}
</>
) : (
tab.name
)
}
/> />
))} ))}
</Tabs> </Tabs>
@@ -87,7 +78,7 @@ RoutedTabs.propTypes = {
shape({ shape({
id: number.isRequired, id: number.isRequired,
link: string.isRequired, link: string.isRequired,
name: string.isRequired, name: oneOfType([string.isRequired, node.isRequired]),
}) })
).isRequired, ).isRequired,
}; };

View File

@@ -2,13 +2,17 @@ import React, { Component } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Switch, Route, withRouter, Redirect, Link } from 'react-router-dom'; 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 { TabbedCardHeader } from '@components/Card';
import CardCloseButton from '@components/CardCloseButton'; import CardCloseButton from '@components/CardCloseButton';
import RoutedTabs from '@components/RoutedTabs'; import RoutedTabs from '@components/RoutedTabs';
import ContentError from '@components/ContentError'; import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import HostFacts from './HostFacts'; import HostFacts from './HostFacts';
import HostDetail from './HostDetail'; import HostDetail from './HostDetail';
import HostEdit from './HostEdit'; import HostEdit from './HostEdit';
import HostGroups from './HostGroups'; import HostGroups from './HostGroups';
import HostCompletedJobs from './HostCompletedJobs'; import HostCompletedJobs from './HostCompletedJobs';
@@ -46,14 +50,20 @@ class Host extends Component {
} }
async loadHost() { async loadHost() {
const { match, setBreadcrumb } = this.props; const { match, setBreadcrumb, history, inventory } = this.props;
const id = parseInt(match.params.id, 10);
this.setState({ contentError: null, hasContentLoading: true }); this.setState({ contentError: null, hasContentLoading: true });
try { try {
const { data } = await HostsAPI.readDetail(id); const { data } = await HostsAPI.readDetail(
setBreadcrumb(data); match.params.hostId || match.params.id
);
this.setState({ host: data }); this.setState({ host: data });
if (history.location.pathname.startsWith('/hosts')) {
setBreadcrumb(data);
} else {
setBreadcrumb(inventory, data);
}
} catch (err) { } catch (err) {
this.setState({ contentError: err }); this.setState({ contentError: err });
} finally { } finally {
@@ -63,20 +73,41 @@ class Host extends Component {
render() { render() {
const { location, match, history, i18n } = this.props; const { location, match, history, i18n } = this.props;
const { host, hasContentLoading, isInitialized, contentError } = this.state;
const { host, contentError, hasContentLoading, isInitialized } = this.state;
const tabsArray = [ 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`Details`),
{ name: i18n._(t`Groups`), link: `${match.url}/groups`, id: 2 }, 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`), name: i18n._(t`Completed Jobs`),
link: `${match.url}/completed_jobs`, link: `${match.url}/completed_jobs`,
id: 3, id: 3,
}, },
]; ];
if (!history.location.pathname.startsWith('/hosts')) {
tabsArray.unshift({
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Hosts`)}
</>
),
link: `/inventories/inventory/${match.params.id}/hosts`,
id: 99,
});
}
let cardHeader = ( let cardHeader = (
<TabbedCardHeader> <TabbedCardHeader>
<RoutedTabs <RoutedTabs
@@ -101,78 +132,98 @@ class Host extends Component {
cardHeader = null; cardHeader = null;
} }
if (!hasContentLoading && contentError) { if (hasContentLoading) {
return ( return <ContentLoading />;
<PageSection>
<Card className="awx-c-card">
<ContentError error={contentError}>
{contentError.response.status === 404 && (
<span>
{i18n._(`Host not found.`)}{' '}
<Link to="/hosts">{i18n._(`View all Hosts.`)}</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
} }
return ( if (!hasContentLoading && contentError) {
<PageSection> return (
<Card className="awx-c-card"> <Card className="awx-c-card">
{cardHeader} <ContentError error={contentError}>
<Switch> {contentError.response.status === 404 && (
<Redirect from="/hosts/:id" to="/hosts/:id/details" exact /> <span>
{host && ( {i18n._(`Host not found.`)}{' '}
<Route <Link to="/hosts">{i18n._(`View all Hosts.`)}</Link>
path="/hosts/:id/edit" </span>
render={() => <HostEdit match={match} host={host} />}
/>
)} )}
{host && ( </ContentError>
<Route
path="/hosts/:id/details"
render={() => <HostDetail host={host} />}
/>
)}
{host && (
<Route
path="/hosts/:id/facts"
render={() => <HostFacts host={host} />}
/>
)}
{host && (
<Route
path="/hosts/:id/groups"
render={() => <HostGroups host={host} />}
/>
)}
{host && (
<Route
path="/hosts/:id/completed_jobs"
render={() => <HostCompletedJobs host={host} />}
/>
)}
<Route
key="not-found"
path="*"
render={() =>
!hasContentLoading && (
<ContentError isNotFound>
{match.params.id && (
<Link to={`/hosts/${match.params.id}/details`}>
{i18n._(`View Host Details`)}
</Link>
)}
</ContentError>
)
}
/>
,
</Switch>
</Card> </Card>
</PageSection> );
}
const redirect = location.pathname.startsWith('/hosts') ? (
<Redirect from="/hosts/:id" to="/hosts/:id/details" exact />
) : (
<Redirect
from="/inventories/inventory/:id/hosts/:hostId"
to="/inventories/inventory/:id/hosts/:hostId/details"
exact
/>
);
return (
<Card className="awx-c-card">
{cardHeader}
<Switch>
{redirect}
{host && (
<Route
path={[
'/hosts/:id/details',
'/inventories/inventory/:id/hosts/:hostId/details',
]}
render={() => (
<HostDetail
host={host}
onUpdateHost={newHost => this.setState({ host: newHost })}
/>
)}
/>
)}
))
{host && (
<Route
path={[
'/hosts/:id/edit',
'/inventories/inventory/:id/hosts/:hostId/edit',
]}
render={() => <HostEdit match={match} host={host} />}
/>
)}
{host && (
<Route
path="/hosts/:id/facts"
render={() => <HostFacts host={host} />}
/>
)}
{host && (
<Route
path="/hosts/:id/groups"
render={() => <HostGroups host={host} />}
/>
)}
{host && (
<Route
path="/hosts/:id/completed_jobs"
render={() => <HostCompletedJobs host={host} />}
/>
)}
<Route
key="not-found"
path="*"
render={() =>
!hasContentLoading && (
<ContentError isNotFound>
{match.params.id && (
<Link to={`/hosts/${match.params.id}/details`}>
{i18n._(`View Host Details`)}
</Link>
)}
</ContentError>
)
}
/>
,
</Switch>
</Card>
); );
} }
} }

View File

@@ -1,64 +1,167 @@
import React from 'react'; import React, { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link, useHistory, useParams, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Host } from '@types'; import { Host } from '@types';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import { CardBody, CardActionsRow } from '@components/Card'; import { CardBody, CardActionsRow } from '@components/Card';
import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import { DetailList, Detail, UserDateDetail } from '@components/DetailList'; import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
import { VariablesDetail } from '@components/CodeMirrorInput'; 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 }) { function HostDetail({ host, i18n, onUpdateHost }) {
const { created, description, id, modified, name, summary_fields } = host; 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 (
<AlertModal
variant="danger"
title={i18n._(t`Error!`)}
isOpen={toggleError && !toggleLoading}
onClose={() => setToggleError(false)}
>
{i18n._(t`Failed to toggle host.`)}
<ErrorDetail error={toggleError} />
</AlertModal>
);
}
if (!isLoading && deletionError) {
return (
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={() => setDeletionError(false)}
>
{i18n._(t`Failed to delete ${name}.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
);
}
return ( return (
<CardBody> <CardBody>
<Switch
css="padding-bottom: 40px"
id={`host-${id}-toggle`}
label={i18n._(t`On`)}
labelOff={i18n._(t`Off`)}
isChecked={enabled}
isDisabled={!user_capabilities.edit}
onChange={() => handleHostToggle()}
aria-label={i18n._(t`Toggle Host`)}
/>
<DetailList gutter="sm"> <DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={name} /> <Detail label={i18n._(t`Name`)} value={name} />
<Detail
css="display: flex; flex: 1;"
value={<Sparkline jobs={recent_jobs} />}
label={i18n._(t`Activity`)}
/>
<Detail label={i18n._(t`Description`)} value={description} /> <Detail label={i18n._(t`Description`)} value={description} />
{summary_fields.inventory && ( {inventory && (
<Detail <Detail
label={i18n._(t`Inventory`)} label={i18n._(t`Inventory`)}
value={ value={
<Link <Link
to={`/inventories/${ to={`/inventories/${
summary_fields.inventory.kind === 'smart' kind === 'smart' ? 'smart_inventory' : 'inventory'
? 'smart_inventory' }/${inventoryId}/details`}
: 'inventory'
}/${summary_fields.inventory.id}/details`}
> >
{summary_fields.inventory.name} {inventory.name}
</Link> </Link>
} }
/> />
)} )}
<UserDateDetail <UserDateDetail
label={i18n._(t`Created`)}
date={created} date={created}
user={summary_fields.created_by} label={i18n._(t`Created`)}
user={created_by}
/> />
<UserDateDetail <UserDateDetail
label={i18n._(t`Last Modified`)} label={i18n._(t`Last Modified`)}
user={modified_by}
date={modified} date={modified}
user={summary_fields.modified_by}
/> />
<VariablesDetail <VariablesDetail
label={i18n._(t`Variables`)}
value={host.variables} value={host.variables}
rows={6} rows={4}
label={i18n._(t`Variables`)}
/> />
</DetailList> </DetailList>
<CardActionsRow> <CardActionsRow>
{summary_fields.user_capabilities && {user_capabilities && user_capabilities.edit && (
summary_fields.user_capabilities.edit && ( <Button
<Button aria-label={i18n._(t`edit`)}
aria-label={i18n._(t`edit`)} component={Link}
component={Link} to={
to={`/hosts/${id}/edit`} pathname.startsWith('/inventories')
> ? `/inventories/inventory/${inventoryId}/hosts/${inventoryHostId}/edit`
{i18n._(t`Edit`)} : `/hosts/${id}/edit`
</Button> }
)} >
{i18n._(t`Edit`)}
</Button>
)}
{user_capabilities && user_capabilities.delete && (
<DeleteButton
onConfirm={() => handleHostDelete()}
modalTitle={i18n._(t`Delete Host`)}
name={host.name}
/>
)}
</CardActionsRow> </CardActionsRow>
</CardBody> </CardBody>
); );

View File

@@ -50,8 +50,7 @@ describe('<HostDetail />', () => {
test('should show edit button for users with edit permission', async () => { test('should show edit button for users with edit permission', async () => {
const wrapper = mountWithContexts(<HostDetail host={mockHost} />); const wrapper = mountWithContexts(<HostDetail host={mockHost} />);
// VariablesDetail has two buttons const editButton = wrapper.find('Button[aria-label="edit"]');
const editButton = wrapper.find('Button').at(2);
expect(editButton.text()).toEqual('Edit'); expect(editButton.text()).toEqual('Edit');
expect(editButton.prop('to')).toBe('/hosts/1/edit'); expect(editButton.prop('to')).toBe('/hosts/1/edit');
}); });
@@ -61,7 +60,6 @@ describe('<HostDetail />', () => {
readOnlyHost.summary_fields.user_capabilities.edit = false; readOnlyHost.summary_fields.user_capabilities.edit = false;
const wrapper = mountWithContexts(<HostDetail host={readOnlyHost} />); const wrapper = mountWithContexts(<HostDetail host={readOnlyHost} />);
await waitForElement(wrapper, 'HostDetail'); await waitForElement(wrapper, 'HostDetail');
// VariablesDetail has two buttons expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0);
expect(wrapper.find('Button').length).toBe(2);
}); });
}); });

View File

@@ -2,7 +2,7 @@ import React, { Component, Fragment } from 'react';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core'; import { Card } from '@patternfly/react-core';
import { HostsAPI } from '@api'; import { HostsAPI } from '@api';
import AlertModal from '@components/AlertModal'; import AlertModal from '@components/AlertModal';
@@ -180,76 +180,74 @@ class HostsList extends Component {
return ( return (
<Fragment> <Fragment>
<PageSection> <Card>
<Card> <PaginatedDataList
<PaginatedDataList contentError={contentError}
contentError={contentError} hasContentLoading={hasContentLoading}
hasContentLoading={hasContentLoading} items={hosts}
items={hosts} itemCount={itemCount}
itemCount={itemCount} pluralizedItemName={i18n._(t`Hosts`)}
pluralizedItemName={i18n._(t`Hosts`)} qsConfig={QS_CONFIG}
qsConfig={QS_CONFIG} onRowClick={this.handleSelect}
onRowClick={this.handleSelect} toolbarSearchColumns={[
toolbarSearchColumns={[ {
{ name: i18n._(t`Name`),
name: i18n._(t`Name`), key: 'name',
key: 'name', isDefault: true,
isDefault: true, },
}, {
{ name: i18n._(t`Created By (Username)`),
name: i18n._(t`Created By (Username)`), key: 'created_by__username',
key: 'created_by__username', },
}, {
{ name: i18n._(t`Modified By (Username)`),
name: i18n._(t`Modified By (Username)`), key: 'modified_by__username',
key: 'modified_by__username', },
}, ]}
]} toolbarSortColumns={[
toolbarSortColumns={[ {
{ name: i18n._(t`Name`),
name: i18n._(t`Name`), key: 'name',
key: 'name', },
}, ]}
]} renderToolbar={props => (
renderToolbar={props => ( <DataListToolbar
<DataListToolbar {...props}
{...props} showSelectAll
showSelectAll isAllSelected={isAllSelected}
isAllSelected={isAllSelected} onSelectAll={this.handleSelectAll}
onSelectAll={this.handleSelectAll} qsConfig={QS_CONFIG}
qsConfig={QS_CONFIG} additionalControls={[
additionalControls={[ <ToolbarDeleteButton
<ToolbarDeleteButton key="delete"
key="delete" onDelete={this.handleHostDelete}
onDelete={this.handleHostDelete} itemsToDelete={selected}
itemsToDelete={selected} pluralizedItemName={i18n._(t`Hosts`)}
pluralizedItemName={i18n._(t`Hosts`)} />,
/>, canAdd ? (
canAdd ? ( <ToolbarAddButton key="add" linkTo={`${match.url}/add`} />
<ToolbarAddButton key="add" linkTo={`${match.url}/add`} /> ) : null,
) : null, ]}
]} />
/> )}
)} renderItem={o => (
renderItem={o => ( <HostListItem
<HostListItem key={o.id}
key={o.id} host={o}
host={o} detailUrl={`${match.url}/${o.id}/details`}
detailUrl={`${match.url}/${o.id}`} isSelected={selected.some(row => row.id === o.id)}
isSelected={selected.some(row => row.id === o.id)} onSelect={() => this.handleSelect(o)}
onSelect={() => this.handleSelect(o)} onToggleHost={this.handleHostToggle}
toggleHost={this.handleHostToggle} toggleLoading={toggleLoading === o.id}
toggleLoading={toggleLoading === o.id} />
/> )}
)} emptyStateControls={
emptyStateControls={ canAdd ? (
canAdd ? ( <ToolbarAddButton key="add" linkTo={`${match.url}/add`} />
<ToolbarAddButton key="add" linkTo={`${match.url}/add`} /> ) : null
) : null }
} />
/> </Card>
</Card>
</PageSection>
{toggleError && !toggleLoading && ( {toggleError && !toggleLoading && (
<AlertModal <AlertModal
variant="danger" variant="danger"

View File

@@ -34,7 +34,7 @@ class HostListItem extends React.Component {
isSelected, isSelected,
onSelect, onSelect,
detailUrl, detailUrl,
toggleHost, onToggleHost,
toggleLoading, toggleLoading,
i18n, i18n,
} = this.props; } = this.props;
@@ -93,7 +93,7 @@ class HostListItem extends React.Component {
toggleLoading || toggleLoading ||
!host.summary_fields.user_capabilities.edit !host.summary_fields.user_capabilities.edit
} }
onChange={() => toggleHost(host)} onChange={() => onToggleHost(host)}
aria-label={i18n._(t`Toggle host`)} aria-label={i18n._(t`Toggle host`)}
/> />
</Tooltip> </Tooltip>

View File

@@ -4,7 +4,7 @@ import { mountWithContexts } from '@testUtils/enzymeHelpers';
import HostsListItem from './HostListItem'; import HostsListItem from './HostListItem';
let toggleHost; let onToggleHost;
const mockHost = { const mockHost = {
id: 1, id: 1,
@@ -24,7 +24,7 @@ const mockHost = {
describe('<HostsListItem />', () => { describe('<HostsListItem />', () => {
beforeEach(() => { beforeEach(() => {
toggleHost = jest.fn(); onToggleHost = jest.fn();
}); });
afterEach(() => { afterEach(() => {
@@ -38,7 +38,7 @@ describe('<HostsListItem />', () => {
detailUrl="/host/1" detailUrl="/host/1"
onSelect={() => {}} onSelect={() => {}}
host={mockHost} host={mockHost}
toggleHost={toggleHost} onToggleHost={onToggleHost}
/> />
); );
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
@@ -52,7 +52,7 @@ describe('<HostsListItem />', () => {
detailUrl="/host/1" detailUrl="/host/1"
onSelect={() => {}} onSelect={() => {}}
host={copyMockHost} host={copyMockHost}
toggleHost={toggleHost} onToggleHost={onToggleHost}
/> />
); );
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
@@ -64,7 +64,7 @@ describe('<HostsListItem />', () => {
detailUrl="/host/1" detailUrl="/host/1"
onSelect={() => {}} onSelect={() => {}}
host={mockHost} host={mockHost}
toggleHost={toggleHost} onToggleHost={onToggleHost}
/> />
); );
wrapper wrapper
@@ -72,7 +72,7 @@ describe('<HostsListItem />', () => {
.first() .first()
.find('input') .find('input')
.simulate('change'); .simulate('change');
expect(toggleHost).toHaveBeenCalledWith(mockHost); expect(onToggleHost).toHaveBeenCalledWith(mockHost);
}); });
test('handles toggle click when host is disabled', () => { test('handles toggle click when host is disabled', () => {
@@ -82,7 +82,7 @@ describe('<HostsListItem />', () => {
detailUrl="/host/1" detailUrl="/host/1"
onSelect={() => {}} onSelect={() => {}}
host={mockHost} host={mockHost}
toggleHost={toggleHost} onToggleHost={onToggleHost}
/> />
); );
wrapper wrapper
@@ -90,6 +90,6 @@ describe('<HostsListItem />', () => {
.first() .first()
.find('input') .find('input')
.simulate('change'); .simulate('change');
expect(toggleHost).toHaveBeenCalledWith(mockHost); expect(onToggleHost).toHaveBeenCalledWith(mockHost);
}); });
}); });

View File

@@ -2,6 +2,7 @@ import React, { Component, Fragment } from 'react';
import { Route, withRouter, Switch } from 'react-router-dom'; import { Route, withRouter, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { PageSection } from '@patternfly/react-core';
import { Config } from '@contexts/Config'; import { Config } from '@contexts/Config';
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs'; import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
@@ -46,31 +47,31 @@ class Hosts extends Component {
}; };
render() { render() {
const { match, history, location } = this.props; const { match } = this.props;
const { breadcrumbConfig } = this.state; const { breadcrumbConfig } = this.state;
return ( return (
<Fragment> <Fragment>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<Switch> <PageSection>
<Route path={`${match.path}/add`} render={() => <HostAdd />} /> <Switch>
<Route <Route path={`${match.path}/add`} render={() => <HostAdd />} />
path={`${match.path}/:id`} <Route
render={() => ( path={`${match.path}/:id`}
<Config> render={() => (
{({ me }) => ( <Config>
<Host {({ me }) => (
history={history} <Host
location={location} setBreadcrumb={this.setBreadcrumbConfig}
setBreadcrumb={this.setBreadcrumbConfig} me={me || {}}
me={me || {}} />
/> )}
)} </Config>
</Config> )}
)} />
/> <Route path={`${match.path}`} render={() => <HostList />} />
<Route path={`${match.path}`} render={() => <HostList />} /> </Switch>
</Switch> </PageSection>
</Fragment> </Fragment>
); );
} }

View File

@@ -27,7 +27,7 @@ class Inventories extends Component {
}; };
} }
setBreadCrumbConfig = (inventory, group) => { setBreadCrumbConfig = (inventory, nestedResource) => {
const { i18n } = this.props; const { i18n } = this.props;
if (!inventory) { if (!inventory) {
return; return;
@@ -39,33 +39,49 @@ class Inventories extends Component {
'/inventories/inventory/add': i18n._(t`Create New Inventory`), '/inventories/inventory/add': i18n._(t`Create New Inventory`),
'/inventories/smart_inventory/add': i18n._(t`Create New Smart Inventory`), '/inventories/smart_inventory/add': i18n._(t`Create New Smart Inventory`),
[`/inventories/${inventoryKind}/${inventory.id}`]: `${inventory.name}`, [`/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._( [`/inventories/${inventoryKind}/${inventory.id}/access`]: i18n._(
t`Access` t`Access`
), ),
[`/inventories/${inventoryKind}/${inventory.id}/completed_jobs`]: i18n._( [`/inventories/${inventoryKind}/${inventory.id}/completed_jobs`]: i18n._(
t`Completed Jobs` 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}/hosts`]: i18n._(t`Hosts`),
[`/inventories/${inventoryKind}/${inventory.id}/sources`]: i18n._(
t`Sources`
),
[`/inventories/${inventoryKind}/${inventory.id}/hosts/add`]: i18n._( [`/inventories/${inventoryKind}/${inventory.id}/hosts/add`]: i18n._(
t`Create New Host` t`Create New Host`
), ),
[`/inventories/inventory/${inventory.id}/sources`]: i18n._(t`Sources`), [`/inventories/${inventoryKind}/${inventory.id}/hosts/${nestedResource &&
[`/inventories/inventory/${inventory.id}/groups`]: i18n._(t`Groups`), nestedResource.id}/edit`]: i18n._(t`Edit Details`),
[`/inventories/inventory/${inventory.id}/groups/add`]: i18n._( [`/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` t`Create New Group`
), ),
[`/inventories/inventory/${inventory.id}/groups/${group && [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource &&
group.id}`]: `${group && group.name}`, nestedResource.id}/edit`]: i18n._(t`Edit Details`),
[`/inventories/inventory/${inventory.id}/groups/${group && [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource &&
group.id}/details`]: i18n._(t`Group Details`), nestedResource.id}/details`]: i18n._(t`Group Details`),
[`/inventories/inventory/${inventory.id}/groups/${group && [`/inventories/${inventoryKind}/${inventory.id}/groups/${nestedResource &&
group.id}/edit`]: i18n._(t`Edit Details`), nestedResource.id}`]: `${nestedResource && nestedResource.name}`,
}; };
this.setState({ breadcrumbConfig }); this.setState({ breadcrumbConfig });
}; };

View File

@@ -8,14 +8,15 @@ import CardCloseButton from '@components/CardCloseButton';
import ContentError from '@components/ContentError'; import ContentError from '@components/ContentError';
import RoutedTabs from '@components/RoutedTabs'; import RoutedTabs from '@components/RoutedTabs';
import { ResourceAccessList } from '@components/ResourceAccessList'; import { ResourceAccessList } from '@components/ResourceAccessList';
import ContentLoading from '@components/ContentLoading';
import InventoryDetail from './InventoryDetail'; import InventoryDetail from './InventoryDetail';
import InventoryHosts from './InventoryHosts';
import InventoryHostAdd from './InventoryHostAdd';
import InventoryGroups from './InventoryGroups'; import InventoryGroups from './InventoryGroups';
import InventoryCompletedJobs from './InventoryCompletedJobs'; import InventoryCompletedJobs from './InventoryCompletedJobs';
import InventorySources from './InventorySources'; import InventorySources from './InventorySources';
import { InventoriesAPI } from '@api'; import { InventoriesAPI } from '@api';
import InventoryEdit from './InventoryEdit'; import InventoryEdit from './InventoryEdit';
import InventoryHosts from './InventoryHosts/InventoryHosts';
function Inventory({ history, i18n, location, match, setBreadcrumb }) { function Inventory({ history, i18n, location, match, setBreadcrumb }) {
const [contentError, setContentError] = useState(null); const [contentError, setContentError] = useState(null);
@@ -61,10 +62,14 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
if ( if (
location.pathname.endsWith('edit') || location.pathname.endsWith('edit') ||
location.pathname.endsWith('add') || location.pathname.endsWith('add') ||
location.pathname.includes('groups/') location.pathname.includes('groups/') ||
location.pathname.includes('hosts/')
) { ) {
cardHeader = null; cardHeader = null;
} }
if (hasContentLoading) {
return <ContentLoading />;
}
if (!hasContentLoading && contentError) { if (!hasContentLoading && contentError) {
return ( return (
@@ -111,9 +116,16 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
render={() => <InventoryEdit inventory={inventory} />} render={() => <InventoryEdit inventory={inventory} />}
/>, />,
<Route <Route
key="host-add" key="hosts"
path="/inventories/inventory/:id/hosts/add" path="/inventories/inventory/:id/hosts"
render={() => <InventoryHostAdd />} render={() => (
<InventoryHosts
match={match}
setBreadcrumb={setBreadcrumb}
i18n={i18n}
inventory={inventory}
/>
)}
/>, />,
<Route <Route
key="access" key="access"
@@ -138,11 +150,6 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
/> />
)} )}
/>, />,
<Route
key="hosts"
path="/inventories/inventory/:id/hosts"
render={() => <InventoryHosts />}
/>,
<Route <Route
key="sources" key="sources"
path="/inventories/inventory/:id/sources" path="/inventories/inventory/:id/sources"

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { Switch, Route, withRouter, Link, Redirect } from 'react-router-dom'; import { Switch, Route, withRouter, Link, Redirect } from 'react-router-dom';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { GroupsAPI } from '@api'; import { GroupsAPI } from '@api';
import CardCloseButton from '@components/CardCloseButton'; import CardCloseButton from '@components/CardCloseButton';
import RoutedTabs from '@components/RoutedTabs'; import RoutedTabs from '@components/RoutedTabs';
@@ -40,10 +41,14 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) {
const tabsArray = [ const tabsArray = [
{ {
name: i18n._(t`Return to Groups`), name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Groups`)}
</>
),
link: `/inventories/inventory/${inventory.id}/groups`, link: `/inventories/inventory/${inventory.id}/groups`,
id: 99, id: 99,
isNestedTabs: true,
}, },
{ {
name: i18n._(t`Details`), 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 // In cases where a user manipulates the url such that they try to navigate to a
// that is not associated with the Inventory Id in the Url this Content Error is thrown. // Inventory Group that is not associated with the Inventory Id in the Url this
// Inventory Groups have a 1: 1 relationship to Inventories thus their Ids must corrolate. // Content Error is thrown. Inventory Groups have a 1:1 relationship to Inventories
// thus their Ids must corrolate.
if (contentLoading) { if (contentLoading) {
return <ContentLoading />; return <ContentLoading />;

View File

@@ -62,10 +62,10 @@ describe('<InventoryGroup />', () => {
test('renders successfully', async () => { test('renders successfully', async () => {
expect(wrapper.length).toBe(1); expect(wrapper.length).toBe(1);
}); });
test('expect all tabs to exist, including Return to Groups', async () => { test('expect all tabs to exist, including Back to Groups', async () => {
expect(wrapper.find('button[aria-label="Return to Groups"]').length).toBe( expect(
1 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="Details"]').length).toBe(1);
expect(wrapper.find('button[aria-label="Related Groups"]').length).toBe(1); expect(wrapper.find('button[aria-label="Related Groups"]').length).toBe(1);
expect(wrapper.find('button[aria-label="Hosts"]').length).toBe(1); expect(wrapper.find('button[aria-label="Hosts"]').length).toBe(1);

View File

@@ -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 (
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
items={hosts}
itemCount={hostCount}
pluralizedItemName={i18n._(t`Hosts`)}
qsConfig={QS_CONFIG}
toolbarColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: true,
isNumeric: true,
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: true,
isNumeric: true,
},
]}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG}
additionalControls={[
<ToolbarDeleteButton
key="delete"
onDelete={handleDelete}
itemsToDelete={selected}
pluralizedItemName={i18n._(t`Hosts`)}
/>,
canAdd && (
<ToolbarAddButton
key="add"
linkTo={`/inventories/inventory/${match.params.id}/hosts/add`}
/>
),
]}
/>
)}
renderItem={o => (
<InventoryHostItem
key={o.id}
host={o}
detailUrl={`/inventories/inventory/${match.params.id}/hosts/${o.id}/details`}
editUrl={`/inventories/inventory/${match.params.id}/hosts/${o.id}/edit`}
isSelected={selected.some(row => row.id === o.id)}
onSelect={() => handleSelect(o)}
toggleHost={handleToggle}
toggleLoading={toggleLoading === o.id}
/>
)}
emptyStateControls={
canAdd && (
<ToolbarAddButton
key="add"
linkTo={`/inventories/inventory/${match.params.id}/add`}
/>
)
}
/>
{toggleError && !toggleLoading && (
<AlertModal
variant="danger"
title={i18n._(t`Error!`)}
isOpen={toggleError && !toggleLoading}
onClose={() => setToggleError(false)}
>
{i18n._(t`Failed to toggle host.`)}
<ErrorDetail error={toggleError} />
</AlertModal>
)}
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)}
>
{i18n._(t`Failed to delete one or more hosts.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
)}
</>
);
}
export default withI18n()(withRouter(InventoryHostList));

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { InventoriesAPI, HostsAPI } from '@api'; import { InventoriesAPI, HostsAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import InventoryHosts from './InventoryHosts'; import InventoryHostList from './InventoryHostList';
import mockInventory from '../shared/data.inventory.json'; import mockInventory from '../shared/data.inventory.json';
jest.mock('@api'); jest.mock('@api');
@@ -62,7 +62,7 @@ const mockHosts = [
}, },
]; ];
describe('<InventoryHosts />', () => { describe('<InventoryHostList />', () => {
let wrapper; let wrapper;
beforeEach(async () => { beforeEach(async () => {
@@ -81,7 +81,7 @@ describe('<InventoryHosts />', () => {
}, },
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<InventoryHosts />); wrapper = mountWithContexts(<InventoryHostList />);
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
}); });
@@ -91,7 +91,7 @@ describe('<InventoryHosts />', () => {
}); });
test('initially renders successfully', () => { 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 () => { test('should fetch hosts from api and render them in the list', async () => {
@@ -261,7 +261,9 @@ describe('<InventoryHosts />', () => {
}, },
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<InventoryHosts inventory={mockInventory} />); wrapper = mountWithContexts(
<InventoryHostList inventory={mockInventory} />
);
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ToolbarAddButton').length).toBe(0); expect(wrapper.find('ToolbarAddButton').length).toBe(0);
@@ -272,7 +274,9 @@ describe('<InventoryHosts />', () => {
Promise.reject(new Error()) Promise.reject(new Error())
); );
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<InventoryHosts inventory={mockInventory} />); wrapper = mountWithContexts(
<InventoryHostList inventory={mockInventory} />
);
}); });
await waitForElement(wrapper, 'ContentError', el => el.length === 1); await waitForElement(wrapper, 'ContentError', el => el.length === 1);
}); });

View File

@@ -1,228 +1,46 @@
import React, { useEffect, useState } from 'react'; import React from 'react';
import { withRouter } from 'react-router-dom'; import { Switch, Route, 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 Host from '../../Host/Host';
import DataListToolbar from '@components/DataListToolbar'; import InventoryHostList from './InventoryHostList';
import ErrorDetail from '@components/ErrorDetail'; import InventoryHostAdd from '../InventoryHostAdd';
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;
function InventoryHosts({ match, setBreadcrumb, i18n, inventory }) {
return ( return (
<> <Switch>
<PaginatedDataList <Route
contentError={contentError} key="host-add"
hasContentLoading={isLoading} path="/inventories/inventory/:id/hosts/add"
items={hosts} render={() => <InventoryHostAdd match={match} />}
itemCount={hostCount}
pluralizedItemName={i18n._(t`Hosts`)}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG}
additionalControls={[
<ToolbarDeleteButton
key="delete"
onDelete={handleDelete}
itemsToDelete={selected}
pluralizedItemName={i18n._(t`Hosts`)}
/>,
canAdd && (
<ToolbarAddButton
key="add"
linkTo={`/inventories/inventory/${match.params.id}/hosts/add`}
/>
),
]}
/>
)}
renderItem={o => (
<InventoryHostItem
key={o.id}
host={o}
detailUrl={`/inventories/inventory/${match.params.id}/hosts/${o.id}/details`}
editUrl={`/inventories/inventory/${match.params.id}/hosts/${o.id}/edit`}
isSelected={selected.some(row => row.id === o.id)}
onSelect={() => handleSelect(o)}
toggleHost={handleToggle}
toggleLoading={toggleLoading === o.id}
/>
)}
emptyStateControls={
canAdd && (
<ToolbarAddButton
key="add"
linkTo={`/inventories/inventory/${match.params.id}/add`}
/>
)
}
/> />
,
{toggleError && !toggleLoading && ( <Route
<AlertModal key="host"
variant="danger" path="/inventories/inventory/:id/hosts/:hostId"
title={i18n._(t`Error!`)} render={() => (
isOpen={toggleError && !toggleLoading} <Host
onClose={() => setToggleError(false)} setBreadcrumb={setBreadcrumb}
> match={match}
{i18n._(t`Failed to toggle host.`)} i18n={i18n}
<ErrorDetail error={toggleError} /> inventory={inventory}
</AlertModal> />
)} )}
/>
{deletionError && ( ,
<AlertModal <Route
isOpen={deletionError} key="host-list"
variant="danger" path="/inventories/inventory/:id/hosts/"
title={i18n._(t`Error!`)} render={() => (
onClose={() => setDeletionError(null)} <InventoryHostList
> match={match}
{i18n._(t`Failed to delete one or more hosts.`)} setBreadcrumb={setBreadcrumb}
<ErrorDetail error={deletionError} /> i18n={i18n}
</AlertModal> />
)} )}
</> />
,
</Switch>
); );
} }
export default withI18n()(withRouter(InventoryHosts)); export default withRouter(InventoryHosts);

View File

@@ -1 +1 @@
export { default } from './InventoryHosts'; export { default } from './InventoryHostList';