Merge pull request #5366 from marshmalien/inv-hosts-list

Inventory Host List

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2019-11-25 17:39:33 +00:00 committed by GitHub
commit bdd63f36a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 931 additions and 278 deletions

View File

@ -14,6 +14,10 @@ class Inventories extends InstanceGroupsMixin(Base) {
params,
});
}
readHosts(id, params) {
return this.http.get(`${this.baseUrl}${id}/hosts/`, { params });
}
}
export default Inventories;

View File

@ -123,8 +123,7 @@ describe('<NotificationList />', () => {
const items = wrapper.find('NotificationListItem');
items
.at(1)
.find('Switch')
.at(1)
.find('PFSwitch[aria-label="Toggle notification success"]')
.prop('onChange')();
expect(MockModelAPI.associateNotificationTemplate).toHaveBeenCalledWith(
1,
@ -151,8 +150,7 @@ describe('<NotificationList />', () => {
const items = wrapper.find('NotificationListItem');
items
.at(0)
.find('Switch')
.at(2)
.find('PFSwitch[aria-label="Toggle notification failure"]')
.prop('onChange')();
expect(MockModelAPI.associateNotificationTemplate).toHaveBeenCalledWith(
1,
@ -180,8 +178,7 @@ describe('<NotificationList />', () => {
const items = wrapper.find('NotificationListItem');
items
.at(0)
.find('Switch')
.at(0)
.find('PFSwitch[aria-label="Toggle notification start"]')
.prop('onChange')();
expect(MockModelAPI.associateNotificationTemplate).toHaveBeenCalledWith(
1,
@ -208,8 +205,7 @@ describe('<NotificationList />', () => {
const items = wrapper.find('NotificationListItem');
items
.at(0)
.find('Switch')
.at(1)
.find('PFSwitch[aria-label="Toggle notification success"]')
.prop('onChange')();
expect(MockModelAPI.disassociateNotificationTemplate).toHaveBeenCalledWith(
1,
@ -236,8 +232,7 @@ describe('<NotificationList />', () => {
const items = wrapper.find('NotificationListItem');
items
.at(1)
.find('Switch')
.at(2)
.find('PFSwitch[aria-label="Toggle notification failure"]')
.prop('onChange')();
expect(MockModelAPI.disassociateNotificationTemplate).toHaveBeenCalledWith(
1,
@ -264,8 +259,7 @@ describe('<NotificationList />', () => {
const items = wrapper.find('NotificationListItem');
items
.at(2)
.find('Switch')
.at(0)
.find('PFSwitch[aria-label="Toggle notification start"]')
.prop('onChange')();
expect(MockModelAPI.disassociateNotificationTemplate).toHaveBeenCalledWith(
1,

View File

@ -4,12 +4,12 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
import {
Switch as PFSwitch,
DataListItem,
DataListItemRow,
DataListItemCells,
DataListCell as PFDataListCell,
} from '@patternfly/react-core';
import Switch from '@components/Switch';
import styled from 'styled-components';
@ -24,13 +24,6 @@ const DataListCell = styled(PFDataListCell)`
}
`;
const Switch = styled(PFSwitch)`
display: flex;
flex-wrap: no-wrap;
/* workaround PF bug; used in calculating switch width: */
--pf-c-switch__toggle-icon--Offset: 0.125rem;
`;
function NotificationListItem(props) {
const {
canToggleNotifications,

View File

@ -89,8 +89,7 @@ describe('<NotificationListItem canToggleNotifications />', () => {
/>
);
wrapper
.find('Switch')
.first()
.find('Switch[aria-label="Toggle notification start"]')
.find('input')
.simulate('change');
expect(toggleNotification).toHaveBeenCalledWith(9000, false, 'started');
@ -108,8 +107,7 @@ describe('<NotificationListItem canToggleNotifications />', () => {
/>
);
wrapper
.find('Switch')
.at(1)
.find('Switch[aria-label="Toggle notification success"]')
.find('input')
.simulate('change');
expect(toggleNotification).toHaveBeenCalledWith(9000, true, 'success');
@ -127,8 +125,7 @@ describe('<NotificationListItem canToggleNotifications />', () => {
/>
);
wrapper
.find('Switch')
.at(1)
.find('Switch[aria-label="Toggle notification success"]')
.find('input')
.simulate('change');
expect(toggleNotification).toHaveBeenCalledWith(9000, false, 'success');
@ -146,8 +143,7 @@ describe('<NotificationListItem canToggleNotifications />', () => {
/>
);
wrapper
.find('Switch')
.at(2)
.find('Switch[aria-label="Toggle notification failure"]')
.find('input')
.simulate('change');
expect(toggleNotification).toHaveBeenCalledWith(9000, true, 'error');
@ -165,8 +161,7 @@ describe('<NotificationListItem canToggleNotifications />', () => {
/>
);
wrapper
.find('Switch')
.at(2)
.find('Switch[aria-label="Toggle notification failure"]')
.find('input')
.simulate('change');
expect(toggleNotification).toHaveBeenCalledWith(9000, false, 'error');

View File

@ -313,7 +313,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
className="pf-c-data-list__cell NotificationListItem__DataListCell-w674ng-0 dXsFLF"
righthalf="true"
>
<NotificationListItem__Switch
<Switch
aria-label="Toggle notification start"
id="notification-9000-started-toggle"
isChecked={false}
@ -329,17 +329,17 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "NotificationListItem__Switch-w674ng-1",
"componentId": "Switch-sc-1xwas62-0",
"isStatic": true,
"lastClassName": "hfzRow",
"lastClassName": "eJQXYh",
"rules": Array [
"display:flex;flex-wrap:no-wrap;--pf-c-switch__toggle-icon--Offset:0.125rem;",
],
},
"displayName": "NotificationListItem__Switch",
"displayName": "Switch",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "NotificationListItem__Switch-w674ng-1",
"styledComponentId": "Switch-sc-1xwas62-0",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
@ -354,9 +354,9 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
labelOff="Start"
onChange={[Function]}
>
<Component
<PFSwitch
aria-label="Toggle notification start"
className="NotificationListItem__Switch-w674ng-1 hfzRow"
className="Switch-sc-1xwas62-0 eJQXYh"
id="notification-9000-started-toggle"
isChecked={false}
isDisabled={false}
@ -369,7 +369,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
componentProps={
Object {
"aria-label": "Toggle notification start",
"className": "NotificationListItem__Switch-w674ng-1 hfzRow",
"className": "Switch-sc-1xwas62-0 eJQXYh",
"id": "notification-9000-started-toggle",
"isChecked": false,
"isDisabled": false,
@ -382,7 +382,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
>
<Switch
aria-label="Toggle notification start"
className="NotificationListItem__Switch-w674ng-1 hfzRow"
className="Switch-sc-1xwas62-0 eJQXYh"
id="notification-9000-started-toggle"
isChecked={false}
isDisabled={false}
@ -397,7 +397,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
}
>
<label
className="pf-c-switch NotificationListItem__Switch-w674ng-1 hfzRow"
className="pf-c-switch Switch-sc-1xwas62-0 eJQXYh"
htmlFor="notification-9000-started-toggle"
>
<input
@ -430,10 +430,10 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
</label>
</Switch>
</ComponentWithOuia>
</Component>
</PFSwitch>
</StyledComponent>
</NotificationListItem__Switch>
<NotificationListItem__Switch
</Switch>
<Switch
aria-label="Toggle notification success"
id="notification-9000-success-toggle"
isChecked={false}
@ -449,17 +449,17 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "NotificationListItem__Switch-w674ng-1",
"componentId": "Switch-sc-1xwas62-0",
"isStatic": true,
"lastClassName": "hfzRow",
"lastClassName": "eJQXYh",
"rules": Array [
"display:flex;flex-wrap:no-wrap;--pf-c-switch__toggle-icon--Offset:0.125rem;",
],
},
"displayName": "NotificationListItem__Switch",
"displayName": "Switch",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "NotificationListItem__Switch-w674ng-1",
"styledComponentId": "Switch-sc-1xwas62-0",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
@ -474,9 +474,9 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
labelOff="Success"
onChange={[Function]}
>
<Component
<PFSwitch
aria-label="Toggle notification success"
className="NotificationListItem__Switch-w674ng-1 hfzRow"
className="Switch-sc-1xwas62-0 eJQXYh"
id="notification-9000-success-toggle"
isChecked={false}
isDisabled={false}
@ -489,7 +489,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
componentProps={
Object {
"aria-label": "Toggle notification success",
"className": "NotificationListItem__Switch-w674ng-1 hfzRow",
"className": "Switch-sc-1xwas62-0 eJQXYh",
"id": "notification-9000-success-toggle",
"isChecked": false,
"isDisabled": false,
@ -502,7 +502,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
>
<Switch
aria-label="Toggle notification success"
className="NotificationListItem__Switch-w674ng-1 hfzRow"
className="Switch-sc-1xwas62-0 eJQXYh"
id="notification-9000-success-toggle"
isChecked={false}
isDisabled={false}
@ -517,7 +517,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
}
>
<label
className="pf-c-switch NotificationListItem__Switch-w674ng-1 hfzRow"
className="pf-c-switch Switch-sc-1xwas62-0 eJQXYh"
htmlFor="notification-9000-success-toggle"
>
<input
@ -550,10 +550,10 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
</label>
</Switch>
</ComponentWithOuia>
</Component>
</PFSwitch>
</StyledComponent>
</NotificationListItem__Switch>
<NotificationListItem__Switch
</Switch>
<Switch
aria-label="Toggle notification failure"
id="notification-9000-error-toggle"
isChecked={false}
@ -569,17 +569,17 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "NotificationListItem__Switch-w674ng-1",
"componentId": "Switch-sc-1xwas62-0",
"isStatic": true,
"lastClassName": "hfzRow",
"lastClassName": "eJQXYh",
"rules": Array [
"display:flex;flex-wrap:no-wrap;--pf-c-switch__toggle-icon--Offset:0.125rem;",
],
},
"displayName": "NotificationListItem__Switch",
"displayName": "Switch",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "NotificationListItem__Switch-w674ng-1",
"styledComponentId": "Switch-sc-1xwas62-0",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
@ -594,9 +594,9 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
labelOff="Failure"
onChange={[Function]}
>
<Component
<PFSwitch
aria-label="Toggle notification failure"
className="NotificationListItem__Switch-w674ng-1 hfzRow"
className="Switch-sc-1xwas62-0 eJQXYh"
id="notification-9000-error-toggle"
isChecked={false}
isDisabled={false}
@ -609,7 +609,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
componentProps={
Object {
"aria-label": "Toggle notification failure",
"className": "NotificationListItem__Switch-w674ng-1 hfzRow",
"className": "Switch-sc-1xwas62-0 eJQXYh",
"id": "notification-9000-error-toggle",
"isChecked": false,
"isDisabled": false,
@ -622,7 +622,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
>
<Switch
aria-label="Toggle notification failure"
className="NotificationListItem__Switch-w674ng-1 hfzRow"
className="Switch-sc-1xwas62-0 eJQXYh"
id="notification-9000-error-toggle"
isChecked={false}
isDisabled={false}
@ -637,7 +637,7 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
}
>
<label
className="pf-c-switch NotificationListItem__Switch-w674ng-1 hfzRow"
className="pf-c-switch Switch-sc-1xwas62-0 eJQXYh"
htmlFor="notification-9000-error-toggle"
>
<input
@ -670,9 +670,9 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
</label>
</Switch>
</ComponentWithOuia>
</Component>
</PFSwitch>
</StyledComponent>
</NotificationListItem__Switch>
</Switch>
</div>
</DataListCell>
</StyledComponent>

View File

@ -0,0 +1,10 @@
import { Switch } from '@patternfly/react-core';
import styled from 'styled-components';
Switch.displayName = 'PFSwitch';
export default styled(Switch)`
display: flex;
flex-wrap: no-wrap;
/* workaround PF bug; used in calculating switch width: */
--pf-c-switch__toggle-icon--Offset: 0.125rem;
`;

View File

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

View File

@ -35,7 +35,7 @@ class HostsList extends Component {
itemCount: 0,
actions: null,
toggleError: false,
toggleLoading: false,
toggleLoading: null,
};
this.handleSelectAll = this.handleSelectAll.bind(this);
@ -101,7 +101,7 @@ class HostsList extends Component {
async handleHostToggle(hostToToggle) {
const { hosts } = this.state;
this.setState({ toggleLoading: true });
this.setState({ toggleLoading: hostToToggle.id });
try {
const { data: updatedHost } = await HostsAPI.update(hostToToggle.id, {
enabled: !hostToToggle.enabled,
@ -114,7 +114,7 @@ class HostsList extends Component {
} catch (err) {
this.setState({ toggleError: true });
} finally {
this.setState({ toggleLoading: false });
this.setState({ toggleLoading: null });
}
}
@ -237,7 +237,7 @@ class HostsList extends Component {
isSelected={selected.some(row => row.id === o.id)}
onSelect={() => this.handleSelect(o)}
toggleHost={this.handleHostToggle}
toggleLoading={toggleLoading}
toggleLoading={toggleLoading === o.id}
/>
)}
emptyStateControls={

View File

@ -6,7 +6,6 @@ import {
DataListItem,
DataListItemRow,
DataListItemCells,
Switch as PFSwitch,
Tooltip,
} from '@patternfly/react-core';
import { Link } from 'react-router-dom';
@ -17,18 +16,10 @@ import DataListCell from '@components/DataListCell';
import DataListCheck from '@components/DataListCheck';
import ListActionButton from '@components/ListActionButton';
import { Sparkline } from '@components/Sparkline';
import Switch from '@components/Switch';
import VerticalSeparator from '@components/VerticalSeparator';
import { Host } from '@types';
import styled from 'styled-components';
const Switch = styled(PFSwitch)`
display: flex;
flex-wrap: no-wrap;
/* workaround PF bug; used in calculating switch width: */
--pf-c-switch__toggle-icon--Offset: 0.125rem;
`;
class HostListItem extends React.Component {
static propTypes = {
host: Host.isRequired,

View File

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

View File

@ -6,8 +6,8 @@ import { t } from '@lingui/macro';
import { Config } from '@contexts/Config';
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
import HostsList from './HostList/HostList';
import HostAdd from './HostAdd/HostAdd';
import HostList from './HostList';
import HostAdd from './HostAdd';
import Host from './Host';
class Hosts extends Component {
@ -69,7 +69,7 @@ class Hosts extends Component {
</Config>
)}
/>
<Route path={`${match.path}`} render={() => <HostsList />} />
<Route path={`${match.path}`} render={() => <HostList />} />
</Switch>
</Fragment>
);

View File

@ -52,6 +52,9 @@ class Inventories extends Component {
t`Completed Jobs`
),
[`/inventories/${inventoryKind}/${inventory.id}/hosts`]: i18n._(t`Hosts`),
[`/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`),
};

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React, { useEffect, useState } from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { Card, CardHeader, PageSection } from '@patternfly/react-core';
@ -9,184 +9,159 @@ import RoutedTabs from '@components/RoutedTabs';
import { ResourceAccessList } from '@components/ResourceAccessList';
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';
class Inventory extends Component {
constructor(props) {
super(props);
function Inventory({ history, i18n, location, match, setBreadcrumb }) {
const [contentError, setContentError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(true);
const [inventory, setInventory] = useState(null);
this.state = {
contentError: null,
hasContentLoading: true,
inventory: null,
};
this.loadInventory = this.loadInventory.bind(this);
}
async componentDidMount() {
await this.loadInventory();
}
async componentDidUpdate(prevProps) {
const { location, match } = this.props;
const url = `/inventories/inventory/${match.params.id}/`;
if (
prevProps.location.pathname.startsWith(url) &&
prevProps.location !== location &&
location.pathname === `${url}details`
) {
await this.loadInventory();
}
}
async loadInventory() {
const { setBreadcrumb, match } = this.props;
const { id } = match.params;
this.setState({ contentError: null, hasContentLoading: true });
try {
const { data } = await InventoriesAPI.readDetail(id);
setBreadcrumb(data);
this.setState({ inventory: data });
} catch (err) {
this.setState({ contentError: err });
} finally {
this.setState({ hasContentLoading: false });
}
}
render() {
const { history, i18n, location, match } = this.props;
const { contentError, hasContentLoading, inventory } = this.state;
const tabsArray = [
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
{ name: i18n._(t`Access`), link: `${match.url}/access`, id: 1 },
{ name: i18n._(t`Groups`), link: `${match.url}/groups`, id: 2 },
{ name: i18n._(t`Hosts`), link: `${match.url}/hosts`, id: 3 },
{ name: i18n._(t`Sources`), link: `${match.url}/sources`, id: 4 },
{
name: i18n._(t`Completed Jobs`),
link: `${match.url}/completed_jobs`,
id: 5,
},
];
let cardHeader = hasContentLoading ? null : (
<CardHeader style={{ padding: 0 }}>
<RoutedTabs history={history} tabsArray={tabsArray} />
<CardCloseButton linkTo="/inventories" />
</CardHeader>
);
if (location.pathname.endsWith('edit')) {
cardHeader = null;
useEffect(() => {
async function fetchData() {
try {
const { data } = await InventoriesAPI.readDetail(match.params.id);
setBreadcrumb(data);
setInventory(data);
} catch (error) {
setContentError(error);
} finally {
setHasContentLoading(false);
}
}
if (!hasContentLoading && contentError) {
return (
<PageSection>
<Card className="awx-c-card">
<ContentError error={contentError}>
{contentError.response.status === 404 && (
<span>
{i18n._(`Inventory not found.`)}{' '}
<Link to="/inventories">
{i18n._(`View all Inventories.`)}
</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
fetchData();
}, [match.params.id, setBreadcrumb]);
const tabsArray = [
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
{ name: i18n._(t`Access`), link: `${match.url}/access`, id: 1 },
{ name: i18n._(t`Groups`), link: `${match.url}/groups`, id: 2 },
{ name: i18n._(t`Hosts`), link: `${match.url}/hosts`, id: 3 },
{ name: i18n._(t`Sources`), link: `${match.url}/sources`, id: 4 },
{
name: i18n._(t`Completed Jobs`),
link: `${match.url}/completed_jobs`,
id: 5,
},
];
let cardHeader = hasContentLoading ? null : (
<CardHeader style={{ padding: 0 }}>
<RoutedTabs history={history} tabsArray={tabsArray} />
<CardCloseButton linkTo="/inventories" />
</CardHeader>
);
if (location.pathname.endsWith('edit') || location.pathname.endsWith('add')) {
cardHeader = null;
}
if (!hasContentLoading && contentError) {
return (
<PageSection>
<Card className="awx-c-card">
{cardHeader}
<Switch>
<Redirect
from="/inventories/inventory/:id"
to="/inventories/inventory/:id/details"
exact
/>
{inventory && [
<Route
key="details"
path="/inventories/inventory/:id/details"
render={() => (
<InventoryDetail
match={match}
hasInventoryLoading={hasContentLoading}
inventory={inventory}
/>
)}
/>,
<Route
key="edit"
path="/inventories/inventory/:id/edit"
render={() => <InventoryEdit inventory={inventory} />}
/>,
<Route
key="access"
path="/inventories/inventory/:id/access"
render={() => (
<ResourceAccessList
resource={inventory}
apiModel={InventoriesAPI}
/>
)}
/>,
<Route
key="groups"
path="/inventories/inventory/:id/groups"
render={() => <InventoryGroups inventory={inventory} />}
/>,
<Route
key="hosts"
path="/inventories/inventory/:id/hosts"
render={() => <InventoryHosts inventory={inventory} />}
/>,
<Route
key="sources"
path="/inventories/inventory/:id/sources"
render={() => <InventorySources inventory={inventory} />}
/>,
<Route
key="completed_jobs"
path="/inventories/inventory/:id/completed_jobs"
render={() => <InventoryCompletedJobs inventory={inventory} />}
/>,
<Route
key="not-found"
path="*"
render={() =>
!hasContentLoading && (
<ContentError isNotFound>
{match.params.id && (
<Link
to={`/inventories/inventory/${match.params.id}/details`}
>
{i18n._(`View Inventory Details`)}
</Link>
)}
</ContentError>
)
}
/>,
]}
</Switch>
<ContentError error={contentError}>
{contentError.response.status === 404 && (
<span>
{i18n._(`Inventory not found.`)}{' '}
<Link to="/inventories">{i18n._(`View all Inventories.`)}</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
return (
<PageSection>
<Card className="awx-c-card">
{cardHeader}
<Switch>
<Redirect
from="/inventories/inventory/:id"
to="/inventories/inventory/:id/details"
exact
/>
{inventory && [
<Route
key="details"
path="/inventories/inventory/:id/details"
render={() => (
<InventoryDetail
match={match}
hasInventoryLoading={hasContentLoading}
inventory={inventory}
/>
)}
/>,
<Route
key="edit"
path="/inventories/inventory/:id/edit"
render={() => <InventoryEdit inventory={inventory} />}
/>,
<Route
key="host-add"
path="/inventories/inventory/:id/hosts/add"
render={() => <InventoryHostAdd />}
/>,
<Route
key="access"
path="/inventories/inventory/:id/access"
render={() => (
<ResourceAccessList
resource={inventory}
apiModel={InventoriesAPI}
/>
)}
/>,
<Route
key="groups"
path="/inventories/inventory/:id/groups"
render={() => <InventoryGroups inventory={inventory} />}
/>,
<Route
key="hosts"
path="/inventories/inventory/:id/hosts"
render={() => <InventoryHosts />}
/>,
<Route
key="sources"
path="/inventories/inventory/:id/sources"
render={() => <InventorySources inventory={inventory} />}
/>,
<Route
key="completed_jobs"
path="/inventories/inventory/:id/completed_jobs"
render={() => <InventoryCompletedJobs inventory={inventory} />}
/>,
<Route
key="not-found"
path="*"
render={() =>
!hasContentLoading && (
<ContentError isNotFound>
{match.params.id && (
<Link
to={`/inventories/inventory/${match.params.id}/details`}
>
{i18n._(`View Inventory Details`)}
</Link>
)}
</ContentError>
)
}
/>,
]}
</Switch>
</Card>
</PageSection>
);
}
export { Inventory as _Inventory };

View File

@ -1,4 +1,5 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { InventoriesAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
@ -12,41 +13,38 @@ InventoriesAPI.readDetail.mockResolvedValue({
});
describe('<Inventory />', () => {
test('initially renders succesfully', async done => {
const wrapper = mountWithContexts(
<Inventory setBreadcrumb={() => {}} match={{ params: { id: 1 } }} />
);
await waitForElement(
wrapper,
'Inventory',
el => el.state('hasContentLoading') === true
);
await waitForElement(
wrapper,
'Inventory',
el => el.state('hasContentLoading') === false
);
let wrapper;
test('initially renders succesfully', async () => {
await act(async () => {
wrapper = mountWithContexts(
<Inventory setBreadcrumb={() => {}} match={{ params: { id: 1 } }} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 6);
done();
});
test('should show content error when user attempts to navigate to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/foobar'],
});
const wrapper = mountWithContexts(<Inventory setBreadcrumb={() => {}} />, {
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
url: '/inventories/inventory/1/foobar',
path: '/inventories/inventory/1/foobar',
await act(async () => {
wrapper = mountWithContexts(<Inventory setBreadcrumb={() => {}} />, {
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
url: '/inventories/inventory/1/foobar',
path: '/inventories/inventory/1/foobar',
},
},
},
},
},
});
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});

View File

@ -0,0 +1,8 @@
import React from 'react';
import { CardBody } from '@patternfly/react-core';
function InventoryHostAdd() {
return <CardBody>Coming soon :)</CardBody>;
}
export default InventoryHostAdd;

View File

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

View File

@ -0,0 +1,105 @@
import React from 'react';
import { string, bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
DataListItem,
DataListItemRow,
DataListItemCells,
Tooltip,
} from '@patternfly/react-core';
import { Link } from 'react-router-dom';
import { PencilAltIcon } from '@patternfly/react-icons';
import ActionButtonCell from '@components/ActionButtonCell';
import DataListCell from '@components/DataListCell';
import DataListCheck from '@components/DataListCheck';
import ListActionButton from '@components/ListActionButton';
import { Sparkline } from '@components/Sparkline';
import Switch from '@components/Switch';
import VerticalSeparator from '@components/VerticalSeparator';
import { Host } from '@types';
function InventoryHostItem(props) {
const {
detailUrl,
host,
i18n,
isSelected,
onSelect,
toggleHost,
toggleLoading,
} = props;
const labelId = `check-action-${host.id}`;
return (
<DataListItem key={host.id} aria-labelledby={labelId}>
<DataListItemRow>
<DataListCheck
id={`select-host-${host.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="divider">
<VerticalSeparator />
<Link to={`${detailUrl}`}>
<b>{host.name}</b>
</Link>
</DataListCell>,
<DataListCell key="recentJobs">
<Sparkline jobs={host.summary_fields.recent_jobs} />
</DataListCell>,
<ActionButtonCell lastcolumn="true" key="action">
<Tooltip
content={i18n._(
t`Indicates if a host is available and should be included
in running jobs. For hosts that are part of an external
inventory, this may be reset by the inventory sync process.`
)}
position="top"
>
<Switch
id={`host-${host.id}-toggle`}
label={i18n._(t`On`)}
labelOff={i18n._(t`Off`)}
isChecked={host.enabled}
isDisabled={
toggleLoading || !host.summary_fields.user_capabilities.edit
}
onChange={() => toggleHost(host)}
aria-label={i18n._(t`Toggle host`)}
/>
</Tooltip>
{host.summary_fields.user_capabilities.edit && (
<Tooltip content={i18n._(t`Edit Host`)} position="top">
<ListActionButton
variant="plain"
component={Link}
to={`/hosts/${host.id}/edit`}
>
<PencilAltIcon />
</ListActionButton>
</Tooltip>
)}
</ActionButtonCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
}
InventoryHostItem.propTypes = {
detailUrl: string.isRequired,
host: Host.isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired,
toggleHost: func.isRequired,
toggleLoading: bool.isRequired,
};
export default withI18n()(InventoryHostItem);

View File

@ -0,0 +1,80 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import InventoryHostItem from './InventoryHostItem';
let toggleHost;
const mockHost = {
id: 1,
name: 'Host 1',
url: '/api/v2/hosts/1',
inventory: 1,
summary_fields: {
inventory: {
id: 1,
name: 'Inv 1',
},
user_capabilities: {
edit: true,
},
},
};
describe.only('<InventoryHostItem />', () => {
beforeEach(() => {
toggleHost = jest.fn();
});
afterEach(() => {
jest.clearAllMocks();
});
test('edit button shown to users with edit capabilities', () => {
const wrapper = mountWithContexts(
<InventoryHostItem
isSelected={false}
detailUrl="/host/1"
onSelect={() => {}}
host={mockHost}
toggleHost={toggleHost}
toggleLoading={false}
/>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
});
test('edit button hidden from users without edit capabilities', () => {
const copyMockHost = Object.assign({}, mockHost);
copyMockHost.summary_fields.user_capabilities.edit = false;
const wrapper = mountWithContexts(
<InventoryHostItem
isSelected={false}
detailUrl="/host/1"
onSelect={() => {}}
host={copyMockHost}
toggleHost={toggleHost}
toggleLoading={false}
/>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});
test('handles toggle click when host is enabled', () => {
const wrapper = mountWithContexts(
<InventoryHostItem
isSelected={false}
detailUrl="/host/1"
onSelect={() => {}}
host={mockHost}
toggleHost={toggleHost}
toggleLoading={false}
/>
);
wrapper
.find('Switch')
.first()
.find('input')
.simulate('change');
expect(toggleHost).toHaveBeenCalledWith(mockHost);
});
});

View File

@ -1,10 +1,225 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
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';
class InventoryHosts extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
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;
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={`/hosts/${o.id}/details`}
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 InventoryHosts;
export default withI18n()(withRouter(InventoryHosts));

View File

@ -0,0 +1,279 @@
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 mockInventory from '../shared/data.inventory.json';
jest.mock('@api');
const mockHosts = [
{
id: 1,
name: 'Host 1',
url: '/api/v2/hosts/1',
inventory: 1,
enabled: true,
summary_fields: {
inventory: {
id: 1,
name: 'inv 1',
},
user_capabilities: {
delete: true,
update: true,
},
},
},
{
id: 2,
name: 'Host 2',
url: '/api/v2/hosts/2',
inventory: 1,
enabled: true,
summary_fields: {
inventory: {
id: 1,
name: 'inv 1',
},
user_capabilities: {
edit: true,
delete: true,
update: true,
},
},
},
{
id: 3,
name: 'Host 3',
url: '/api/v2/hosts/3',
inventory: 1,
enabled: true,
summary_fields: {
inventory: {
id: 1,
name: 'inv 1',
},
user_capabilities: {
delete: false,
update: false,
},
},
},
];
describe('<InventoryHosts />', () => {
let wrapper;
beforeEach(async () => {
InventoriesAPI.readHosts.mockResolvedValue({
data: {
count: mockHosts.length,
results: mockHosts,
},
});
InventoriesAPI.readOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
},
});
await act(async () => {
wrapper = mountWithContexts(<InventoryHosts />);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders successfully', () => {
expect(wrapper.find('InventoryHosts').length).toBe(1);
});
test('should fetch hosts from api and render them in the list', async () => {
expect(InventoriesAPI.readHosts).toHaveBeenCalled();
expect(wrapper.find('InventoryHostItem').length).toBe(3);
});
test('should check and uncheck the row item', async () => {
expect(
wrapper.find('PFDataListCheck[id="select-host-1"]').props().checked
).toBe(false);
await act(async () => {
wrapper.find('PFDataListCheck[id="select-host-1"]').invoke('onChange')(
true
);
});
wrapper.update();
expect(
wrapper.find('PFDataListCheck[id="select-host-1"]').props().checked
).toBe(true);
await act(async () => {
wrapper.find('PFDataListCheck[id="select-host-1"]').invoke('onChange')(
false
);
});
wrapper.update();
expect(
wrapper.find('PFDataListCheck[id="select-host-1"]').props().checked
).toBe(false);
});
test('should check all row items when select all is checked', async () => {
wrapper.find('PFDataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
});
wrapper.update();
wrapper.find('PFDataListCheck').forEach(el => {
expect(el.props().checked).toBe(true);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(false);
});
wrapper.update();
wrapper.find('PFDataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
});
test('should call api if host toggle is clicked', async () => {
HostsAPI.update.mockResolvedValueOnce({
data: { ...mockHosts[1], enabled: false },
});
expect(wrapper.find('PFSwitch[id="host-2-toggle"]').props().isChecked).toBe(
true
);
await act(async () => {
wrapper.find('PFSwitch[id="host-2-toggle"]').invoke('onChange')();
});
wrapper.update();
expect(wrapper.find('PFSwitch[id="host-2-toggle"]').props().isChecked).toBe(
false
);
expect(HostsAPI.update).toHaveBeenCalledTimes(1);
});
test('should show error modal if host is not successfully toggled', async () => {
HostsAPI.update.mockImplementationOnce(() => Promise.reject(new Error()));
await act(async () => {
wrapper.find('PFSwitch[id="host-2-toggle"]').invoke('onChange')();
});
wrapper.update();
await waitForElement(
wrapper,
'Modal',
el => el.props().isOpen === true && el.props().title === 'Error!'
);
await act(async () => {
wrapper.find('ModalBoxCloseButton').invoke('onClose')();
});
await waitForElement(wrapper, 'Modal', el => el.length === 0);
});
test('delete button is disabled if user does not have delete capabilities on a selected host', async () => {
await act(async () => {
wrapper.find('PFDataListCheck[id="select-host-3"]').invoke('onChange')();
});
wrapper.update();
expect(wrapper.find('ToolbarDeleteButton button').props().disabled).toBe(
true
);
});
test('should call api delete hosts for each selected host', async () => {
HostsAPI.destroy = jest.fn();
await act(async () => {
wrapper.find('PFDataListCheck[id="select-host-1"]').invoke('onChange')();
});
wrapper.update();
await act(async () => {
wrapper.find('ToolbarDeleteButton').invoke('onDelete')();
});
wrapper.update();
expect(HostsAPI.destroy).toHaveBeenCalledTimes(1);
});
test('should show error modal when host is not successfully deleted from api', async () => {
HostsAPI.destroy.mockRejectedValue(
new Error({
response: {
config: {
method: 'delete',
url: '/api/v2/hosts/1',
},
data: 'An error occurred',
},
})
);
await act(async () => {
wrapper.find('PFDataListCheck[id="select-host-1"]').invoke('onChange')();
});
wrapper.update();
await act(async () => {
wrapper.find('ToolbarDeleteButton').invoke('onDelete')();
});
await waitForElement(
wrapper,
'Modal',
el => el.props().isOpen === true && el.props().title === 'Error!'
);
await act(async () => {
wrapper.find('ModalBoxCloseButton').invoke('onClose')();
});
await waitForElement(wrapper, 'Modal', el => el.length === 0);
});
test('should show content error if hosts are not successfully fetched from api', async () => {
InventoriesAPI.readHosts.mockImplementation(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper.find('PFDataListCheck[id="select-host-1"]').invoke('onChange')();
});
wrapper.update();
await act(async () => {
wrapper.find('ToolbarDeleteButton').invoke('onDelete')();
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
test('should show Add button for users with ability to POST', async () => {
expect(wrapper.find('ToolbarAddButton').length).toBe(1);
});
test('should hide Add button for users without ability to POST', async () => {
InventoriesAPI.readOptions.mockResolvedValueOnce({
data: {
actions: {
GET: {},
},
},
});
await act(async () => {
wrapper = mountWithContexts(<InventoryHosts inventory={mockInventory} />);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
});
test('should show content error when api throws error on initial render', async () => {
InventoriesAPI.readOptions.mockImplementation(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(<InventoryHosts inventory={mockInventory} />);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
});