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 ( 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>
); );
} }

View File

@@ -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>
); );
} }
} }

View File

@@ -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>

View File

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

View File

@@ -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}
/> />
)} )}

View File

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

View File

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

View File

@@ -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>

View File

@@ -27,7 +27,7 @@ class Inventories extends Component {
}; };
} }
setBreadCrumbConfig = (inventory, group) => { setBreadCrumbConfig = (inventory, nestedResource) => {
const { i18n } = this.props; const { i18n } = this.props;
if (!inventory) { if (!inventory) {
return; return;
@@ -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 });
}; };

View File

@@ -8,14 +8,15 @@ import CardCloseButton from '@components/CardCloseButton';
import ContentError from '@components/ContentError'; import ContentError from '@components/ContentError';
import RoutedTabs from '@components/RoutedTabs'; import RoutedTabs from '@components/RoutedTabs';
import { ResourceAccessList } from '@components/ResourceAccessList'; import { ResourceAccessList } from '@components/ResourceAccessList';
import ContentLoading from '@components/ContentLoading';
import InventoryDetail from './InventoryDetail'; import InventoryDetail from './InventoryDetail';
import InventoryHosts from './InventoryHosts';
import InventoryHostAdd from './InventoryHostAdd';
import InventoryGroups from './InventoryGroups'; import InventoryGroups from './InventoryGroups';
import InventoryCompletedJobs from './InventoryCompletedJobs'; import InventoryCompletedJobs from './InventoryCompletedJobs';
import InventorySources from './InventorySources'; import InventorySources from './InventorySources';
import { InventoriesAPI } from '@api'; import { InventoriesAPI } from '@api';
import InventoryEdit from './InventoryEdit'; import InventoryEdit from './InventoryEdit';
import InventoryHosts from './InventoryHosts/InventoryHosts';
function Inventory({ history, i18n, location, match, setBreadcrumb }) { function Inventory({ history, i18n, location, match, setBreadcrumb }) {
const [contentError, setContentError] = useState(null); const [contentError, setContentError] = useState(null);
@@ -61,10 +62,14 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) {
if ( if (
location.pathname.endsWith('edit') || location.pathname.endsWith('edit') ||
location.pathname.endsWith('add') || location.pathname.endsWith('add') ||
location.pathname.includes('groups/') location.pathname.includes('groups/') ||
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"

View File

@@ -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 />;

View File

@@ -0,0 +1,226 @@
import React, { useEffect, useState } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { getQSConfig, parseQueryString } from '@util/qs';
import { InventoriesAPI, HostsAPI } from '@api';
import AlertModal from '@components/AlertModal';
import DataListToolbar from '@components/DataListToolbar';
import ErrorDetail from '@components/ErrorDetail';
import PaginatedDataList, {
ToolbarAddButton,
ToolbarDeleteButton,
} from '@components/PaginatedDataList';
import InventoryHostItem from './InventoryHostItem';
const QS_CONFIG = getQSConfig('host', {
page: 1,
page_size: 20,
order_by: 'name',
});
function InventoryHostList({ i18n, location, match }) {
const [actions, setActions] = useState(null);
const [contentError, setContentError] = useState(null);
const [deletionError, setDeletionError] = useState(null);
const [hostCount, setHostCount] = useState(0);
const [hosts, setHosts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [selected, setSelected] = useState([]);
const [toggleError, setToggleError] = useState(null);
const [toggleLoading, setToggleLoading] = useState(null);
const fetchHosts = (id, queryString) => {
const params = parseQueryString(QS_CONFIG, queryString);
return InventoriesAPI.readHosts(id, params);
};
useEffect(() => {
async function fetchData() {
try {
const [
{
data: { count, results },
},
{
data: { actions: optionActions },
},
] = await Promise.all([
fetchHosts(match.params.id, location.search),
InventoriesAPI.readOptions(),
]);
setHosts(results);
setHostCount(count);
setActions(optionActions);
} catch (error) {
setContentError(error);
} finally {
setIsLoading(false);
}
}
fetchData();
}, [match.params.id, location]);
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...hosts] : []);
};
const handleSelect = row => {
if (selected.some(s => s.id === row.id)) {
setSelected(selected.filter(s => s.id !== row.id));
} else {
setSelected(selected.concat(row));
}
};
const handleDelete = async () => {
setIsLoading(true);
try {
await Promise.all(selected.map(host => HostsAPI.destroy(host.id)));
} catch (error) {
setDeletionError(error);
} finally {
setSelected([]);
try {
const {
data: { count, results },
} = await fetchHosts(match.params.id, location.search);
setHosts(results);
setHostCount(count);
} catch (error) {
setContentError(error);
} finally {
setIsLoading(false);
}
}
};
const handleToggle = async hostToToggle => {
setToggleLoading(hostToToggle.id);
try {
const { data: updatedHost } = await HostsAPI.update(hostToToggle.id, {
enabled: !hostToToggle.enabled,
});
setHosts(
hosts.map(host => (host.id === updatedHost.id ? updatedHost : host))
);
} catch (error) {
setToggleError(error);
} finally {
setToggleLoading(null);
}
};
const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const isAllSelected = selected.length > 0 && selected.length === hosts.length;
return (
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
items={hosts}
itemCount={hostCount}
pluralizedItemName={i18n._(t`Hosts`)}
qsConfig={QS_CONFIG}
toolbarColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: true,
isNumeric: true,
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: true,
isNumeric: true,
},
]}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG}
additionalControls={[
<ToolbarDeleteButton
key="delete"
onDelete={handleDelete}
itemsToDelete={selected}
pluralizedItemName={i18n._(t`Hosts`)}
/>,
canAdd && (
<ToolbarAddButton
key="add"
linkTo={`/inventories/inventory/${match.params.id}/hosts/add`}
/>
),
]}
/>
)}
renderItem={o => (
<InventoryHostItem
key={o.id}
host={o}
detailUrl={`/inventories/inventory/${match.params.id}/hosts/${o.id}/details`}
editUrl={`/inventories/inventory/${match.params.id}/hosts/${o.id}/edit`}
isSelected={selected.some(row => row.id === o.id)}
onSelect={() => handleSelect(o)}
toggleHost={handleToggle}
toggleLoading={toggleLoading === o.id}
/>
)}
emptyStateControls={
canAdd && (
<ToolbarAddButton
key="add"
linkTo={`/inventories/inventory/${match.params.id}/add`}
/>
)
}
/>
{toggleError && !toggleLoading && (
<AlertModal
variant="danger"
title={i18n._(t`Error!`)}
isOpen={toggleError && !toggleLoading}
onClose={() => setToggleError(false)}
>
{i18n._(t`Failed to toggle host.`)}
<ErrorDetail error={toggleError} />
</AlertModal>
)}
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)}
>
{i18n._(t`Failed to delete one or more hosts.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
)}
</>
);
}
export default withI18n()(withRouter(InventoryHostList));

View File

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

View File

@@ -1,228 +1,46 @@
import React, { useEffect, useState } from 'react'; import React from 'react';
import { withRouter } from 'react-router-dom'; import { Switch, Route, withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { getQSConfig, parseQueryString } from '@util/qs';
import { InventoriesAPI, HostsAPI } from '@api';
import AlertModal from '@components/AlertModal'; import Host from '../../Host/Host';
import DataListToolbar from '@components/DataListToolbar'; import InventoryHostList from './InventoryHostList';
import ErrorDetail from '@components/ErrorDetail'; import 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);

View File

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