1
0
mirror of https://github.com/ansible/awx.git synced 2024-11-01 08:21:15 +03:00

Adds Toggle, Variables, user Link and Delete to Inventory Host and Host Details

If the user comes to Host details through Inventory Host they will get a
Return To Host tab in addition to the others.  This PR allows Inventory Host
to share many of the same components with Host but does add some complexity
to the routing files in Host.jsx
This commit is contained in:
Alex Corey 2020-01-06 09:30:42 -05:00
parent 200be3297a
commit 1db88fe4f6
15 changed files with 668 additions and 365 deletions

View File

@ -57,23 +57,25 @@ function RoutedTabs(props) {
return (
<Tabs activeKey={getActiveTabId()} onSelect={handleTabSelect}>
{tabsArray.map(tab => (
<Tab
aria-label={`${tab.name}`}
eventKey={tab.id}
key={tab.id}
link={tab.link}
title={
tab.isNestedTabs ? (
<>
<CaretLeftIcon /> {tab.name}
</>
) : (
tab.name
)
}
/>
))}
{tabsArray
.filter(tab => tab.isNestedTab || !tab.name.startsWith('Return'))
.map(tab => (
<Tab
aria-label={`${tab.name}`}
eventKey={tab.id}
key={tab.id}
link={tab.link}
title={
tab.isNestedTab ? (
<>
<CaretLeftIcon /> {tab.name}
</>
) : (
tab.name
)
}
/>
))}
</Tabs>
);
}

View File

@ -9,6 +9,9 @@ import RoutedTabs from '@components/RoutedTabs';
import ContentError from '@components/ContentError';
import HostFacts from './HostFacts';
import HostDetail from './HostDetail';
import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import HostEdit from './HostEdit';
import HostGroups from './HostGroups';
import HostCompletedJobs from './HostCompletedJobs';
@ -23,8 +26,16 @@ class Host extends Component {
hasContentLoading: true,
contentError: null,
isInitialized: false,
toggleLoading: false,
toggleError: null,
deletionError: false,
isDeleteModalOpen: false,
};
this.loadHost = this.loadHost.bind(this);
this.handleHostToggle = this.handleHostToggle.bind(this);
this.handleToggleError = this.handleToggleError.bind(this);
this.handleHostDelete = this.handleHostDelete.bind(this);
this.toggleDeleteModal = this.toggleDeleteModal.bind(this);
}
async componentDidMount() {
@ -45,15 +56,54 @@ class Host extends Component {
}
}
toggleDeleteModal() {
const { isDeleteModalOpen } = this.state;
this.setState({ isDeleteModalOpen: !isDeleteModalOpen });
}
async handleHostToggle() {
const { host } = this.state;
this.setState({ toggleLoading: true });
try {
const { data } = await HostsAPI.update(host.id, {
enabled: !host.enabled,
});
this.setState({ host: data });
} catch (err) {
this.setState({ toggleError: err });
} finally {
this.setState({ toggleLoading: null });
}
}
async handleHostDelete() {
const { host } = this.state;
const { match, history } = this.props;
this.setState({ hasContentLoading: true });
try {
await HostsAPI.destroy(host.id);
this.setState({ hasContentLoading: false });
history.push(`/inventories/inventory/${match.params.id}/hosts`);
} catch (err) {
this.setState({ deletionError: err });
}
}
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);
}
setBreadcrumb(inventory, data);
} catch (err) {
this.setState({ contentError: err });
} finally {
@ -61,15 +111,44 @@ class Host extends Component {
}
}
handleToggleError() {
this.setState({ toggleError: false });
}
render() {
const { location, match, history, i18n } = this.props;
const { host, contentError, hasContentLoading, isInitialized } = this.state;
const {
deletionError,
host,
isDeleteModalOpen,
toggleError,
hasContentLoading,
toggleLoading,
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`Return to Hosts`),
link: `/inventories/inventory/${match.params.id}/hosts`,
id: 99,
isNestedTab: !history.location.pathname.startsWith('/hosts'),
},
{
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`,
@ -117,62 +196,99 @@ class Host extends Component {
</PageSection>
);
}
return (
<PageSection>
<Card className="awx-c-card">
{cardHeader}
<Switch>
<Redirect from="/hosts/:id" to="/hosts/:id/details" exact />
{host && (
<>
<PageSection
css={`
${location.pathname.startsWith('/inventories')
? 'padding: 0'
: 'null'}
`}
>
<Card className="awx-c-card">
{cardHeader}
<Switch>
<Redirect from="/hosts/:id" to="/hosts/:id/details" exact />
{host && (
<Route
path={[
'/hosts/:id/edit',
'/inventories/inventory/:id/hosts/:hostId/edit',
]}
render={() => <HostEdit match={match} host={host} />}
/>
)}
{host && (
<Route
path={[
'/hosts/:id/details',
'/inventories/inventory/:id/hosts/:hostId/details',
]}
render={() => (
<HostDetail
match={match}
host={host}
history={history}
onToggleDeleteModal={this.toggleDeleteModal}
isDeleteModalOpen={isDeleteModalOpen}
onHandleHostToggle={this.handleHostToggle}
toggleError={toggleError}
toggleLoading={toggleLoading}
onToggleError={this.handleToggleError}
onHostDelete={this.handleHostDelete}
/>
)}
/>
)}
{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
path="/hosts/:id/edit"
render={() => <HostEdit match={match} host={host} />}
key="not-found"
path="*"
render={() =>
!hasContentLoading && (
<ContentError isNotFound>
{match.params.id && (
<Link to={`/hosts/${match.params.id}/details`}>
{i18n._(`View Host Details`)}
</Link>
)}
</ContentError>
)
}
/>
)}
{host && (
<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>
</PageSection>
,
</Switch>
</Card>
</PageSection>
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={() => this.setState({ deletionError: false })}
>
{i18n._(t`Failed to delete ${host.name}.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
)}
</>
);
}
}

View File

@ -7,14 +7,127 @@ import { Button } from '@patternfly/react-core';
import { CardBody, CardActionsRow } from '@components/Card';
import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
import { VariablesDetail } from '@components/CodeMirrorInput';
import { Sparkline } from '@components/Sparkline';
import Switch from '@components/Switch';
function HostDetail({ host, i18n }) {
const { created, description, id, modified, name, summary_fields } = host;
const ActionButtonWrapper = styled.div`
display: flex;
justify-content: flex-end;
margin-top: 20px;
& > :not(:first-child) {
margin-left: 20px;
}
`;
function HostDetail({
host,
history,
isDeleteModalOpen,
match,
i18n,
toggleError,
toggleLoading,
onHostDelete,
onToggleDeleteModal,
onToggleError,
onHandleHostToggle,
}) {
const { created, description, id, modified, name, summary_fields } = host;
let createdBy = '';
if (created) {
if (summary_fields.created_by && summary_fields.created_by.username) {
createdBy = (
<span>
{i18n._(t`${formatDateString(created)} by `)}{' '}
<Link to={`/users/${summary_fields.created_by.id}`}>
{summary_fields.created_by.username}
</Link>
</span>
);
} else {
createdBy = formatDateString(created);
}
}
let modifiedBy = '';
if (modified) {
if (summary_fields.modified_by && summary_fields.modified_by.username) {
modifiedBy = (
<span>
{i18n._(t`${formatDateString(modified)} by`)}{' '}
<Link to={`/users/${summary_fields.modified_by.id}`}>
{summary_fields.modified_by.username}
</Link>
</span>
);
} else {
modifiedBy = formatDateString(modified);
}
}
if (toggleError && !toggleLoading) {
return (
<AlertModal
variant="danger"
title={i18n._(t`Error!`)}
isOpen={toggleError && !toggleLoading}
onClose={onToggleError}
>
{i18n._(t`Failed to toggle host.`)}
<ErrorDetail error={toggleError} />
</AlertModal>
);
}
if (isDeleteModalOpen) {
return (
<AlertModal
isOpen={isDeleteModalOpen}
title={i18n._(t`Delete Host`)}
variant="danger"
onClose={() => onToggleDeleteModal()}
>
{i18n._(t`Are you sure you want to delete:`)}
<br />
<strong>{host.name}</strong>
<ActionButtonWrapper>
<Button
variant="secondary"
aria-label={i18n._(t`Close`)}
onClick={() => onToggleDeleteModal()}
>
{i18n._(t`Cancel`)}
</Button>
<Button
variant="danger"
aria-label={i18n._(t`Delete`)}
onClick={() => onHostDelete()}
>
{i18n._(t`Delete`)}
</Button>
</ActionButtonWrapper>
</AlertModal>
);
}
return (
<CardBody>
<Switch
css="padding-bottom: 40px; padding-top: 20px"
id={`host-${host.id}-toggle`}
label={i18n._(t`On`)}
labelOff={i18n._(t`Off`)}
isChecked={host.enabled}
isDisabled={!host.summary_fields.user_capabilities.edit}
onChange={onHandleHostToggle}
aria-label={i18n._(t`Toggle Host`)}
/>
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={name} />
<Detail
css="display: flex; flex: 1;"
value={<Sparkline jobs={host.summary_fields.recent_jobs} />}
label={i18n._(t`Activity`)}
/>
<Detail label={i18n._(t`Description`)} value={description} />
{summary_fields.inventory && (
<Detail
@ -32,20 +145,12 @@ function HostDetail({ host, i18n }) {
}
/>
)}
<UserDateDetail
label={i18n._(t`Created`)}
date={created}
user={summary_fields.created_by}
/>
<UserDateDetail
label={i18n._(t`Last Modified`)}
date={modified}
user={summary_fields.modified_by}
/>
<Detail label={i18n._(t`Created`)} value={createdBy} />
<Detail label={i18n._(t`Last Modified`)} value={modifiedBy} />
<VariablesDetail
label={i18n._(t`Variables`)}
value={host.variables}
rows={6}
rows={4}
label={i18n._(t`Variables`)}
/>
</DetailList>
<CardActionsRow>
@ -54,7 +159,11 @@ function HostDetail({ host, i18n }) {
<Button
aria-label={i18n._(t`edit`)}
component={Link}
to={`/hosts/${id}/edit`}
to={
history.location.pathname.startsWith('/inventories')
? `/inventories/inventory/${match.params.id}/hosts/${match.params.hostId}/edit`
: `/hosts/${id}/edit`
}
>
{i18n._(t`Edit`)}
</Button>

View File

@ -50,8 +50,7 @@ describe('<HostDetail />', () => {
test('should show edit button for users with edit permission', async () => {
const wrapper = mountWithContexts(<HostDetail host={mockHost} />);
// 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('<HostDetail />', () => {
readOnlyHost.summary_fields.user_capabilities.edit = false;
const wrapper = mountWithContexts(<HostDetail host={readOnlyHost} />);
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);
});
});

View File

@ -238,7 +238,7 @@ class HostsList extends Component {
detailUrl={`${match.url}/${o.id}`}
isSelected={selected.some(row => row.id === o.id)}
onSelect={() => this.handleSelect(o)}
toggleHost={this.handleHostToggle}
onToggleHost={this.handleHostToggle}
toggleLoading={toggleLoading === o.id}
/>
)}

View File

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

View File

@ -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('<HostsListItem />', () => {
beforeEach(() => {
toggleHost = jest.fn();
onToggleHost = jest.fn();
});
afterEach(() => {
@ -38,7 +38,7 @@ describe('<HostsListItem />', () => {
detailUrl="/host/1"
onSelect={() => {}}
host={mockHost}
toggleHost={toggleHost}
onToggleHost={onToggleHost}
/>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
@ -52,7 +52,7 @@ describe('<HostsListItem />', () => {
detailUrl="/host/1"
onSelect={() => {}}
host={copyMockHost}
toggleHost={toggleHost}
onToggleHost={onToggleHost}
/>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
@ -64,7 +64,7 @@ describe('<HostsListItem />', () => {
detailUrl="/host/1"
onSelect={() => {}}
host={mockHost}
toggleHost={toggleHost}
onToggleHost={onToggleHost}
/>
);
wrapper
@ -72,7 +72,7 @@ describe('<HostsListItem />', () => {
.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('<HostsListItem />', () => {
detailUrl="/host/1"
onSelect={() => {}}
host={mockHost}
toggleHost={toggleHost}
onToggleHost={onToggleHost}
/>
);
wrapper
@ -90,6 +90,6 @@ describe('<HostsListItem />', () => {
.first()
.find('input')
.simulate('change');
expect(toggleHost).toHaveBeenCalledWith(mockHost);
expect(onToggleHost).toHaveBeenCalledWith(mockHost);
});
});

View File

@ -46,14 +46,17 @@ class Hosts extends Component {
};
render() {
const { match, history, location } = this.props;
const { match, history, location, inventory } = this.props;
const { breadcrumbConfig } = this.state;
return (
<Fragment>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<Switch>
<Route path={`${match.path}/add`} render={() => <HostAdd />} />
<Route
path={`${match.path}/add`}
render={() => <HostAdd history={history} />}
/>
<Route
path={`${match.path}/:id`}
render={() => (
@ -64,6 +67,7 @@ class Hosts extends Component {
location={location}
setBreadcrumb={this.setBreadcrumbConfig}
me={me || {}}
inventory={inventory}
/>
)}
</Config>

View File

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

View File

@ -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/') ||
history.location.pathname.includes(`/hosts/`)
) {
cardHeader = null;
}
if (hasContentLoading) {
return <ContentLoading />;
}
if (!hasContentLoading && contentError) {
return (
@ -111,9 +116,16 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
render={() => <InventoryEdit inventory={inventory} />}
/>,
<Route
key="host-add"
path="/inventories/inventory/:id/hosts/add"
render={() => <InventoryHostAdd />}
key="hosts"
path="/inventories/inventory/:id/hosts"
render={() => (
<InventoryHosts
match={match}
setBreadcrumb={setBreadcrumb}
i18n={i18n}
inventory={inventory}
/>
)}
/>,
<Route
key="access"
@ -138,11 +150,6 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
/>
)}
/>,
<Route
key="hosts"
path="/inventories/inventory/:id/hosts"
render={() => <InventoryHosts />}
/>,
<Route
key="sources"
path="/inventories/inventory/:id/sources"

View File

@ -43,7 +43,7 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) {
name: i18n._(t`Return to Groups`),
link: `/inventories/inventory/${inventory.id}/groups`,
id: 99,
isNestedTabs: true,
isNestedTab: true,
},
{
name: i18n._(t`Details`),
@ -65,9 +65,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 <ContentLoading />;

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 { 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('<InventoryHosts />', () => {
describe('<InventoryHostList />', () => {
let wrapper;
beforeEach(async () => {
@ -81,7 +81,7 @@ describe('<InventoryHosts />', () => {
},
});
await act(async () => {
wrapper = mountWithContexts(<InventoryHosts />);
wrapper = mountWithContexts(<InventoryHostList />);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
@ -91,7 +91,7 @@ describe('<InventoryHosts />', () => {
});
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('<InventoryHosts />', () => {
},
});
await act(async () => {
wrapper = mountWithContexts(<InventoryHosts inventory={mockInventory} />);
wrapper = mountWithContexts(
<InventoryHostList inventory={mockInventory} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
@ -272,7 +274,9 @@ describe('<InventoryHosts />', () => {
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(<InventoryHosts inventory={mockInventory} />);
wrapper = mountWithContexts(
<InventoryHostList inventory={mockInventory} />
);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});

View File

@ -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 HostAdd from '../InventoryHostAdd';
function InventoryHosts({ match, setBreadcrumb, i18n, inventory }) {
return (
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
items={hosts}
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`}
/>
)
}
<Switch>
<Route
key="host-add"
path="/inventories/inventory/:id/hosts/add"
render={() => <HostAdd match={match} />}
/>
{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>
)}
</>
,
<Route
key="details and edit"
path="/inventories/inventory/:id/hosts/:hostId"
render={() => (
<Host
setBreadcrumb={setBreadcrumb}
match={match}
i18n={i18n}
inventory={inventory}
/>
)}
/>
,
<Route
key="host-list"
path="/inventories/inventory/:id/hosts/"
render={() => (
<InventoryHostList
match={match}
setBreadcrumb={setBreadcrumb}
i18n={i18n}
/>
)}
/>
,
</Switch>
);
}
export default withI18n()(withRouter(InventoryHosts));
export default withRouter(InventoryHosts);

View File

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