mirror of
https://github.com/ansible/awx.git
synced 2026-05-13 04:17:36 -02:30
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:
@@ -57,23 +57,25 @@ function RoutedTabs(props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs activeKey={getActiveTabId()} onSelect={handleTabSelect}>
|
<Tabs activeKey={getActiveTabId()} onSelect={handleTabSelect}>
|
||||||
{tabsArray.map(tab => (
|
{tabsArray
|
||||||
<Tab
|
.filter(tab => tab.isNestedTab || !tab.name.startsWith('Return'))
|
||||||
aria-label={`${tab.name}`}
|
.map(tab => (
|
||||||
eventKey={tab.id}
|
<Tab
|
||||||
key={tab.id}
|
aria-label={`${tab.name}`}
|
||||||
link={tab.link}
|
eventKey={tab.id}
|
||||||
title={
|
key={tab.id}
|
||||||
tab.isNestedTabs ? (
|
link={tab.link}
|
||||||
<>
|
title={
|
||||||
<CaretLeftIcon /> {tab.name}
|
tab.isNestedTab ? (
|
||||||
</>
|
<>
|
||||||
) : (
|
<CaretLeftIcon /> {tab.name}
|
||||||
tab.name
|
</>
|
||||||
)
|
) : (
|
||||||
}
|
tab.name
|
||||||
/>
|
)
|
||||||
))}
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import RoutedTabs from '@components/RoutedTabs';
|
|||||||
import ContentError from '@components/ContentError';
|
import ContentError from '@components/ContentError';
|
||||||
import HostFacts from './HostFacts';
|
import HostFacts from './HostFacts';
|
||||||
import HostDetail from './HostDetail';
|
import HostDetail from './HostDetail';
|
||||||
|
import AlertModal from '@components/AlertModal';
|
||||||
|
import ErrorDetail from '@components/ErrorDetail';
|
||||||
|
|
||||||
import HostEdit from './HostEdit';
|
import HostEdit from './HostEdit';
|
||||||
import HostGroups from './HostGroups';
|
import HostGroups from './HostGroups';
|
||||||
import HostCompletedJobs from './HostCompletedJobs';
|
import HostCompletedJobs from './HostCompletedJobs';
|
||||||
@@ -23,8 +26,16 @@ class Host extends Component {
|
|||||||
hasContentLoading: true,
|
hasContentLoading: true,
|
||||||
contentError: null,
|
contentError: null,
|
||||||
isInitialized: false,
|
isInitialized: false,
|
||||||
|
toggleLoading: false,
|
||||||
|
toggleError: null,
|
||||||
|
deletionError: false,
|
||||||
|
isDeleteModalOpen: false,
|
||||||
};
|
};
|
||||||
this.loadHost = this.loadHost.bind(this);
|
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() {
|
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() {
|
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);
|
||||||
|
}
|
||||||
|
setBreadcrumb(inventory, data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ contentError: err });
|
this.setState({ contentError: err });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -61,15 +111,44 @@ class Host extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleToggleError() {
|
||||||
|
this.setState({ toggleError: false });
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { location, match, history, i18n } = this.props;
|
const { location, match, history, i18n } = this.props;
|
||||||
|
const {
|
||||||
const { host, contentError, hasContentLoading, isInitialized } = this.state;
|
deletionError,
|
||||||
|
host,
|
||||||
|
isDeleteModalOpen,
|
||||||
|
toggleError,
|
||||||
|
hasContentLoading,
|
||||||
|
toggleLoading,
|
||||||
|
isInitialized,
|
||||||
|
contentError,
|
||||||
|
} = 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`Return to Hosts`),
|
||||||
{ name: i18n._(t`Groups`), link: `${match.url}/groups`, id: 2 },
|
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`),
|
name: i18n._(t`Completed Jobs`),
|
||||||
link: `${match.url}/completed_jobs`,
|
link: `${match.url}/completed_jobs`,
|
||||||
@@ -117,62 +196,99 @@ class Host extends Component {
|
|||||||
</PageSection>
|
</PageSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageSection>
|
<>
|
||||||
<Card className="awx-c-card">
|
<PageSection
|
||||||
{cardHeader}
|
css={`
|
||||||
<Switch>
|
${location.pathname.startsWith('/inventories')
|
||||||
<Redirect from="/hosts/:id" to="/hosts/:id/details" exact />
|
? 'padding: 0'
|
||||||
{host && (
|
: '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
|
<Route
|
||||||
path="/hosts/:id/edit"
|
key="not-found"
|
||||||
render={() => <HostEdit match={match} host={host} />}
|
path="*"
|
||||||
|
render={() =>
|
||||||
|
!hasContentLoading && (
|
||||||
|
<ContentError isNotFound>
|
||||||
|
{match.params.id && (
|
||||||
|
<Link to={`/hosts/${match.params.id}/details`}>
|
||||||
|
{i18n._(`View Host Details`)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</ContentError>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
,
|
||||||
{host && (
|
</Switch>
|
||||||
<Route
|
</Card>
|
||||||
path="/hosts/:id/details"
|
</PageSection>
|
||||||
render={() => <HostDetail host={host} />}
|
{deletionError && (
|
||||||
/>
|
<AlertModal
|
||||||
)}
|
isOpen={deletionError}
|
||||||
{host && (
|
variant="danger"
|
||||||
<Route
|
title={i18n._(t`Error!`)}
|
||||||
path="/hosts/:id/facts"
|
onClose={() => this.setState({ deletionError: false })}
|
||||||
render={() => <HostFacts host={host} />}
|
>
|
||||||
/>
|
{i18n._(t`Failed to delete ${host.name}.`)}
|
||||||
)}
|
<ErrorDetail error={deletionError} />
|
||||||
{host && (
|
</AlertModal>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,127 @@ import { Button } from '@patternfly/react-core';
|
|||||||
import { CardBody, CardActionsRow } from '@components/Card';
|
import { CardBody, CardActionsRow } from '@components/Card';
|
||||||
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 Switch from '@components/Switch';
|
||||||
|
|
||||||
function HostDetail({ host, i18n }) {
|
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 (
|
return (
|
||||||
<CardBody>
|
<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">
|
<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={host.summary_fields.recent_jobs} />}
|
||||||
|
label={i18n._(t`Activity`)}
|
||||||
|
/>
|
||||||
<Detail label={i18n._(t`Description`)} value={description} />
|
<Detail label={i18n._(t`Description`)} value={description} />
|
||||||
{summary_fields.inventory && (
|
{summary_fields.inventory && (
|
||||||
<Detail
|
<Detail
|
||||||
@@ -32,20 +145,12 @@ function HostDetail({ host, i18n }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<UserDateDetail
|
<Detail label={i18n._(t`Created`)} value={createdBy} />
|
||||||
label={i18n._(t`Created`)}
|
<Detail label={i18n._(t`Last Modified`)} value={modifiedBy} />
|
||||||
date={created}
|
|
||||||
user={summary_fields.created_by}
|
|
||||||
/>
|
|
||||||
<UserDateDetail
|
|
||||||
label={i18n._(t`Last 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>
|
||||||
@@ -54,7 +159,11 @@ function HostDetail({ host, i18n }) {
|
|||||||
<Button
|
<Button
|
||||||
aria-label={i18n._(t`edit`)}
|
aria-label={i18n._(t`edit`)}
|
||||||
component={Link}
|
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`)}
|
{i18n._(t`Edit`)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ class HostsList extends Component {
|
|||||||
detailUrl={`${match.url}/${o.id}`}
|
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)}
|
||||||
toggleHost={this.handleHostToggle}
|
onToggleHost={this.handleHostToggle}
|
||||||
toggleLoading={toggleLoading === o.id}
|
toggleLoading={toggleLoading === o.id}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,14 +46,17 @@ class Hosts extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { match, history, location } = this.props;
|
const { match, history, location, inventory } = this.props;
|
||||||
const { breadcrumbConfig } = this.state;
|
const { breadcrumbConfig } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={`${match.path}/add`} render={() => <HostAdd />} />
|
<Route
|
||||||
|
path={`${match.path}/add`}
|
||||||
|
render={() => <HostAdd history={history} />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path={`${match.path}/:id`}
|
path={`${match.path}/:id`}
|
||||||
render={() => (
|
render={() => (
|
||||||
@@ -64,6 +67,7 @@ class Hosts extends Component {
|
|||||||
location={location}
|
location={location}
|
||||||
setBreadcrumb={this.setBreadcrumbConfig}
|
setBreadcrumb={this.setBreadcrumbConfig}
|
||||||
me={me || {}}
|
me={me || {}}
|
||||||
|
inventory={inventory}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Config>
|
</Config>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -51,21 +51,39 @@ class Inventories extends Component {
|
|||||||
[`/inventories/${inventoryKind}/${inventory.id}/completed_jobs`]: i18n._(
|
[`/inventories/${inventoryKind}/${inventory.id}/completed_jobs`]: i18n._(
|
||||||
t`Completed Jobs`
|
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._(
|
[`/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}/sources`]: i18n._(
|
||||||
[`/inventories/inventory/${inventory.id}/groups`]: i18n._(t`Groups`),
|
t`Sources`
|
||||||
[`/inventories/inventory/${inventory.id}/groups/add`]: i18n._(
|
),
|
||||||
|
[`/inventories/${inventoryKind}/${inventory.id}/groups`]: i18n._(
|
||||||
|
t`Groups`
|
||||||
|
),
|
||||||
|
[`/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}`]: `${nestedResource && nestedResource.name}`,
|
||||||
[`/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}/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 });
|
this.setState({ breadcrumbConfig });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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/') ||
|
||||||
|
history.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"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) {
|
|||||||
name: i18n._(t`Return to Groups`),
|
name: i18n._(t`Return to Groups`),
|
||||||
link: `/inventories/inventory/${inventory.id}/groups`,
|
link: `/inventories/inventory/${inventory.id}/groups`,
|
||||||
id: 99,
|
id: 99,
|
||||||
isNestedTabs: true,
|
isNestedTab: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: i18n._(t`Details`),
|
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
|
// 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 />;
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
@@ -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 HostAdd 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={() => <HostAdd 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="details and edit"
|
||||||
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);
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { default } from './InventoryHosts';
|
export { default } from './InventoryHostList';
|
||||||
|
|||||||
Reference in New Issue
Block a user