mirror of
https://github.com/ansible/awx.git
synced 2026-01-12 18:40:01 -03:30
Add constructed inventory CRUD and subtab routes
* Add constructed inventory API model * Add constructed inventory detail view * Add util to switch inventory url based on "kind"
This commit is contained in:
parent
280ceae267
commit
a3d7c02802
@ -6,6 +6,7 @@ import Config from './models/Config';
|
||||
import CredentialInputSources from './models/CredentialInputSources';
|
||||
import CredentialTypes from './models/CredentialTypes';
|
||||
import Credentials from './models/Credentials';
|
||||
import ConstructedInventories from './models/ConstructedInventories';
|
||||
import Dashboard from './models/Dashboard';
|
||||
import ExecutionEnvironments from './models/ExecutionEnvironments';
|
||||
import Groups from './models/Groups';
|
||||
@ -53,6 +54,7 @@ const ConfigAPI = new Config();
|
||||
const CredentialInputSourcesAPI = new CredentialInputSources();
|
||||
const CredentialTypesAPI = new CredentialTypes();
|
||||
const CredentialsAPI = new Credentials();
|
||||
const ConstructedInventoriesAPI = new ConstructedInventories();
|
||||
const DashboardAPI = new Dashboard();
|
||||
const ExecutionEnvironmentsAPI = new ExecutionEnvironments();
|
||||
const GroupsAPI = new Groups();
|
||||
@ -101,6 +103,7 @@ export {
|
||||
CredentialInputSourcesAPI,
|
||||
CredentialTypesAPI,
|
||||
CredentialsAPI,
|
||||
ConstructedInventoriesAPI,
|
||||
DashboardAPI,
|
||||
ExecutionEnvironmentsAPI,
|
||||
GroupsAPI,
|
||||
|
||||
11
awx/ui/src/api/models/ConstructedInventories.js
Normal file
11
awx/ui/src/api/models/ConstructedInventories.js
Normal file
@ -0,0 +1,11 @@
|
||||
import Base from '../Base';
|
||||
import InstanceGroupsMixin from '../mixins/InstanceGroups.mixin';
|
||||
|
||||
class ConstructedInventories extends InstanceGroupsMixin(Base) {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = 'api/v2/constructed_inventories/';
|
||||
}
|
||||
}
|
||||
|
||||
export default ConstructedInventories;
|
||||
@ -13,6 +13,7 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
||||
this.readGroups = this.readGroups.bind(this);
|
||||
this.readGroupsOptions = this.readGroupsOptions.bind(this);
|
||||
this.promoteGroup = this.promoteGroup.bind(this);
|
||||
this.readSourceInventories = this.readSourceInventories.bind(this);
|
||||
}
|
||||
|
||||
readAccessList(id, params) {
|
||||
@ -72,6 +73,12 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
||||
});
|
||||
}
|
||||
|
||||
readSourceInventories(inventoryId, params) {
|
||||
return this.http.get(`${this.baseUrl}${inventoryId}/source_inventories/`, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
readSources(inventoryId, params) {
|
||||
return this.http.get(`${this.baseUrl}${inventoryId}/inventory_sources/`, {
|
||||
params,
|
||||
|
||||
206
awx/ui/src/screens/Inventory/ConstructedInventory.js
Normal file
206
awx/ui/src/screens/Inventory/ConstructedInventory.js
Normal file
@ -0,0 +1,206 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Link,
|
||||
Switch,
|
||||
Route,
|
||||
Redirect,
|
||||
useRouteMatch,
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
|
||||
import useRequest from 'hooks/useRequest';
|
||||
import { ConstructedInventoriesAPI, InventoriesAPI } from 'api';
|
||||
|
||||
import ContentError from 'components/ContentError';
|
||||
import ContentLoading from 'components/ContentLoading';
|
||||
import JobList from 'components/JobList';
|
||||
import RelatedTemplateList from 'components/RelatedTemplateList';
|
||||
import { ResourceAccessList } from 'components/ResourceAccessList';
|
||||
import RoutedTabs from 'components/RoutedTabs';
|
||||
import ConstructedInventoryDetail from './ConstructedInventoryDetail';
|
||||
import ConstructedInventoryEdit from './ConstructedInventoryEdit';
|
||||
import ConstructedInventoryGroups from './ConstructedInventoryGroups';
|
||||
import ConstructedInventoryHosts from './ConstructedInventoryHosts';
|
||||
import { getInventoryPath } from './shared/utils';
|
||||
|
||||
function ConstructedInventory({ setBreadcrumb }) {
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch('/inventories/constructed_inventory/:id');
|
||||
|
||||
const {
|
||||
result: inventory,
|
||||
error: contentError,
|
||||
isLoading: hasContentLoading,
|
||||
request: fetchInventory,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await ConstructedInventoriesAPI.readDetail(
|
||||
match.params.id
|
||||
);
|
||||
return data;
|
||||
}, [match.params.id]),
|
||||
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInventory();
|
||||
}, [fetchInventory, location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inventory) {
|
||||
setBreadcrumb(inventory);
|
||||
}
|
||||
}, [inventory, setBreadcrumb]);
|
||||
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{t`Back to Inventories`}
|
||||
</>
|
||||
),
|
||||
link: `/inventories`,
|
||||
id: 99,
|
||||
},
|
||||
{ name: t`Details`, link: `${match.url}/details`, id: 0 },
|
||||
{ name: t`Access`, link: `${match.url}/access`, id: 1 },
|
||||
{ name: t`Hosts`, link: `${match.url}/hosts`, id: 2 },
|
||||
{ name: t`Groups`, link: `${match.url}/groups`, id: 3 },
|
||||
{
|
||||
name: t`Jobs`,
|
||||
link: `${match.url}/jobs`,
|
||||
id: 4,
|
||||
},
|
||||
{ name: t`Job Templates`, link: `${match.url}/job_templates`, id: 5 },
|
||||
];
|
||||
|
||||
if (hasContentLoading) {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<ContentLoading />
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (contentError) {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<ContentError error={contentError}>
|
||||
{contentError?.response?.status === 404 && (
|
||||
<span>
|
||||
{t`Constructed Inventory not found.`}{' '}
|
||||
<Link to="/inventories">{t`View all Inventories.`}</Link>
|
||||
</span>
|
||||
)}
|
||||
</ContentError>
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (inventory && inventory?.kind !== 'constructed') {
|
||||
return <Redirect to={`${getInventoryPath(inventory)}/details`} />;
|
||||
}
|
||||
|
||||
let showCardHeader = true;
|
||||
if (['edit'].some((name) => location.pathname.includes(name))) {
|
||||
showCardHeader = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />}
|
||||
<Switch>
|
||||
<Redirect
|
||||
from="/inventories/constructed_inventory/:id"
|
||||
to="/inventories/constructed_inventory/:id/details"
|
||||
exact
|
||||
/>
|
||||
{inventory && [
|
||||
<Route
|
||||
path="/inventories/constructed_inventory/:id/details"
|
||||
key="details"
|
||||
>
|
||||
<ConstructedInventoryDetail
|
||||
inventory={inventory}
|
||||
hasInventoryLoading={hasContentLoading}
|
||||
/>
|
||||
</Route>,
|
||||
<Route
|
||||
key="edit"
|
||||
path="/inventories/constructed_inventory/:id/edit"
|
||||
>
|
||||
<ConstructedInventoryEdit />
|
||||
</Route>,
|
||||
<Route
|
||||
path="/inventories/constructed_inventory/:id/access"
|
||||
key="access"
|
||||
>
|
||||
<ResourceAccessList
|
||||
resource={inventory}
|
||||
apiModel={InventoriesAPI}
|
||||
/>
|
||||
</Route>,
|
||||
<Route
|
||||
path="/inventories/constructed_inventory/:id/hosts"
|
||||
key="hosts"
|
||||
>
|
||||
<ConstructedInventoryHosts />
|
||||
</Route>,
|
||||
<Route
|
||||
path="/inventories/constructed_inventory/:id/groups"
|
||||
key="groups"
|
||||
>
|
||||
<ConstructedInventoryGroups />
|
||||
</Route>,
|
||||
<Route
|
||||
key="jobs"
|
||||
path="/inventories/constructed_inventory/:id/jobs"
|
||||
>
|
||||
<JobList
|
||||
defaultParams={{
|
||||
or__job__inventory: inventory.id,
|
||||
or__adhoccommand__inventory: inventory.id,
|
||||
or__inventoryupdate__inventory_source__inventory:
|
||||
inventory.id,
|
||||
or__workflowjob__inventory: inventory.id,
|
||||
}}
|
||||
/>
|
||||
</Route>,
|
||||
<Route
|
||||
key="job_templates"
|
||||
path="/inventories/constructed_inventory/:id/job_templates"
|
||||
>
|
||||
<RelatedTemplateList
|
||||
searchParams={{ inventory__id: inventory.id }}
|
||||
/>
|
||||
</Route>,
|
||||
]}
|
||||
<Route path="*" key="not-found">
|
||||
<ContentError isNotFound>
|
||||
{match.params.id && (
|
||||
<Link
|
||||
to={`/inventories/constructed_inventory/${match.params.id}/details`}
|
||||
>
|
||||
{t`View Constructed Inventory Details`}
|
||||
</Link>
|
||||
)}
|
||||
</ContentError>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
export { ConstructedInventory as _ConstructedInventory };
|
||||
export default ConstructedInventory;
|
||||
73
awx/ui/src/screens/Inventory/ConstructedInventory.test.js
Normal file
73
awx/ui/src/screens/Inventory/ConstructedInventory.test.js
Normal file
@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { ConstructedInventoriesAPI } from 'api';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import mockInventory from './shared/data.inventory.json';
|
||||
import ConstructedInventory from './ConstructedInventory';
|
||||
|
||||
jest.mock('../../api');
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useRouteMatch: () => ({
|
||||
url: '/constructed_inventories/1',
|
||||
params: { id: 1 },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('<ConstructedInventory />', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(async () => {
|
||||
ConstructedInventoriesAPI.readDetail.mockResolvedValue({
|
||||
data: mockInventory,
|
||||
});
|
||||
});
|
||||
|
||||
test('should render expected tabs', async () => {
|
||||
const expectedTabs = [
|
||||
'Back to Inventories',
|
||||
'Details',
|
||||
'Access',
|
||||
'Hosts',
|
||||
'Groups',
|
||||
'Jobs',
|
||||
'Job Templates',
|
||||
];
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ConstructedInventory setBreadcrumb={() => {}} />
|
||||
);
|
||||
});
|
||||
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
||||
expect(tab.text()).toEqual(expectedTabs[index]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should show content error when user attempts to navigate to erroneous route', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/constructed_inventory/1/foobar'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ConstructedInventory setBreadcrumb={() => {}} />,
|
||||
{
|
||||
context: {
|
||||
router: {
|
||||
history,
|
||||
route: {
|
||||
location: history.location,
|
||||
match: {
|
||||
params: { id: 1 },
|
||||
url: '/inventories/constructed_inventory/1/foobar',
|
||||
path: '/inventories/constructed_inventory/1/foobar',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,18 @@
|
||||
/* eslint i18next/no-literal-string: "off" */
|
||||
import React from 'react';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
import { CardBody } from 'components/Card';
|
||||
|
||||
function ConstructedInventoryAdd() {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div>Coming Soon!</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConstructedInventoryAdd;
|
||||
@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import ConstructedInventoryAdd from './ConstructedInventoryAdd';
|
||||
|
||||
describe('<ConstructedInventoryAdd />', () => {
|
||||
test('initially renders successfully', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ConstructedInventoryAdd />);
|
||||
});
|
||||
expect(wrapper.length).toBe(1);
|
||||
expect(wrapper.find('ConstructedInventoryAdd').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './ConstructedInventoryAdd';
|
||||
@ -0,0 +1,288 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
TextList,
|
||||
TextListItem,
|
||||
TextListItemVariants,
|
||||
TextListVariants,
|
||||
} from '@patternfly/react-core';
|
||||
import AlertModal from 'components/AlertModal';
|
||||
import { CardBody, CardActionsRow } from 'components/Card';
|
||||
import { DetailList, Detail, UserDateDetail } from 'components/DetailList';
|
||||
import { VariablesDetail } from 'components/CodeEditor';
|
||||
import DeleteButton from 'components/DeleteButton';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
import ContentError from 'components/ContentError';
|
||||
import ContentLoading from 'components/ContentLoading';
|
||||
import ChipGroup from 'components/ChipGroup';
|
||||
import Popover from 'components/Popover';
|
||||
import { InventoriesAPI, ConstructedInventoriesAPI } from 'api';
|
||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||
import { Inventory } from 'types';
|
||||
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
|
||||
import InstanceGroupLabels from 'components/InstanceGroupLabels';
|
||||
import getHelpText from '../shared/Inventory.helptext';
|
||||
|
||||
function ConstructedInventoryDetail({ inventory }) {
|
||||
const history = useHistory();
|
||||
const helpText = getHelpText();
|
||||
|
||||
const {
|
||||
result: { instanceGroups, sourceInventories, actions },
|
||||
request: fetchRelatedDetails,
|
||||
error: contentError,
|
||||
isLoading,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const [response, sourceInvResponse, options] = await Promise.all([
|
||||
InventoriesAPI.readInstanceGroups(inventory.id),
|
||||
InventoriesAPI.readSourceInventories(inventory.id),
|
||||
ConstructedInventoriesAPI.readOptions(inventory.id),
|
||||
]);
|
||||
|
||||
return {
|
||||
instanceGroups: response.data.results,
|
||||
sourceInventories: sourceInvResponse.data.results,
|
||||
actions: options.data.actions.GET,
|
||||
};
|
||||
}, [inventory.id]),
|
||||
{
|
||||
instanceGroups: [],
|
||||
sourceInventories: [],
|
||||
actions: {},
|
||||
isLoading: true,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRelatedDetails();
|
||||
}, [fetchRelatedDetails]);
|
||||
|
||||
const { request: deleteInventory, error: deleteError } = useRequest(
|
||||
useCallback(async () => {
|
||||
await InventoriesAPI.destroy(inventory.id);
|
||||
history.push(`/inventories`);
|
||||
}, [inventory.id, history])
|
||||
);
|
||||
|
||||
const { error, dismissError } = useDismissableError(deleteError);
|
||||
|
||||
const { organization, user_capabilities: userCapabilities } =
|
||||
inventory.summary_fields;
|
||||
|
||||
const deleteDetailsRequests =
|
||||
relatedResourceDeleteRequests.inventory(inventory);
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
if (contentError) {
|
||||
return <ContentError error={contentError} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
<DetailList>
|
||||
<Detail
|
||||
label={t`Name`}
|
||||
value={inventory.name}
|
||||
dataCy="constructed-inventory-name"
|
||||
/>
|
||||
<Detail
|
||||
label={t`Description`}
|
||||
value={inventory.description}
|
||||
dataCy="constructed-inventory-description"
|
||||
/>
|
||||
<Detail
|
||||
label={t`Type`}
|
||||
value={t`Constructed Inventory`}
|
||||
dataCy="constructed-inventory-type"
|
||||
/>
|
||||
<Detail
|
||||
label={actions.limit.label}
|
||||
value={inventory.limit}
|
||||
helpText={actions.limit.help_text}
|
||||
dataCy="constructed-inventory-limit"
|
||||
/>
|
||||
<Detail
|
||||
label={t`Organization`}
|
||||
dataCy="constructed-inventory-organization"
|
||||
value={
|
||||
<Link to={`/organizations/${organization.id}/details`}>
|
||||
{organization.name}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Detail
|
||||
label={actions.total_groups.label}
|
||||
value={inventory.total_groups}
|
||||
helpText={actions.total_groups.help_text}
|
||||
dataCy="constructed-inventory-total-groups"
|
||||
/>
|
||||
<Detail
|
||||
label={actions.total_hosts.label}
|
||||
value={inventory.total_hosts}
|
||||
helpText={actions.total_hosts.help_text}
|
||||
dataCy="constructed-inventory-total-hosts"
|
||||
/>
|
||||
<Detail
|
||||
label={actions.total_inventory_sources.label}
|
||||
value={inventory.total_inventory_sources}
|
||||
helpText={actions.total_inventory_sources.help_text}
|
||||
dataCy="constructed-inventory-sources"
|
||||
/>
|
||||
<Detail
|
||||
label={actions.update_cache_timeout.label}
|
||||
value={inventory.update_cache_timeout}
|
||||
helpText={actions.update_cache_timeout.help_text}
|
||||
dataCy="constructed-inventory-cache-timeout"
|
||||
/>
|
||||
<Detail
|
||||
label={actions.inventory_sources_with_failures.label}
|
||||
value={inventory.inventory_sources_with_failures}
|
||||
helpText={actions.inventory_sources_with_failures.help_text}
|
||||
dataCy="constructed-inventory-sources-with-failures"
|
||||
/>
|
||||
<Detail
|
||||
label={actions.verbosity.label}
|
||||
value={inventory.verbosity}
|
||||
helpText={actions.verbosity.help_text}
|
||||
dataCy="constructed-inventory-verbosity"
|
||||
/>
|
||||
{instanceGroups && (
|
||||
<Detail
|
||||
fullWidth
|
||||
label={t`Instance Groups`}
|
||||
value={<InstanceGroupLabels labels={instanceGroups} isLinkable />}
|
||||
isEmpty={instanceGroups.length === 0}
|
||||
dataCy="constructed-inventory-instance-groups"
|
||||
/>
|
||||
)}
|
||||
{inventory.prevent_instance_group_fallback && (
|
||||
<Detail
|
||||
fullWidth
|
||||
label={t`Enabled Options`}
|
||||
dataCy="constructed-inventory-instance-group-fallback"
|
||||
value={
|
||||
<TextList component={TextListVariants.ul}>
|
||||
{inventory.prevent_instance_group_fallback && (
|
||||
<TextListItem component={TextListItemVariants.li}>
|
||||
{t`Prevent Instance Group Fallback`}
|
||||
<Popover
|
||||
header={t`Prevent Instance Group Fallback`}
|
||||
content={helpText.preventInstanceGroupFallback}
|
||||
/>
|
||||
</TextListItem>
|
||||
)}
|
||||
</TextList>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Detail
|
||||
fullWidth
|
||||
helpText={helpText.labels}
|
||||
dataCy="constructed-inventory-labels"
|
||||
label={t`Labels`}
|
||||
value={
|
||||
<ChipGroup
|
||||
numChips={5}
|
||||
totalChips={inventory.summary_fields.labels?.results?.length}
|
||||
>
|
||||
{inventory.summary_fields.labels?.results?.map((l) => (
|
||||
<Chip key={l.id} isReadOnly>
|
||||
{l.name}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
}
|
||||
isEmpty={inventory.summary_fields.labels?.results?.length === 0}
|
||||
/>
|
||||
<Detail
|
||||
fullWidth
|
||||
label={t`Source Inventories`}
|
||||
value={
|
||||
<ChipGroup
|
||||
numChips={5}
|
||||
totalChips={sourceInventories?.length}
|
||||
ouiaId="source-inventory-chips"
|
||||
>
|
||||
{sourceInventories?.map((sourceInventory) => (
|
||||
<Link
|
||||
key={sourceInventory.id}
|
||||
to={`/inventories/inventory/${sourceInventory.id}/details`}
|
||||
>
|
||||
<Chip key={sourceInventory.id} isReadOnly>
|
||||
{sourceInventory.name}
|
||||
</Chip>
|
||||
</Link>
|
||||
))}
|
||||
</ChipGroup>
|
||||
}
|
||||
isEmpty={sourceInventories?.length === 0}
|
||||
/>
|
||||
<VariablesDetail
|
||||
label={actions.source_vars.label}
|
||||
helpText={helpText.variables()}
|
||||
value={inventory.source_vars}
|
||||
rows={4}
|
||||
name="variables"
|
||||
dataCy="inventory-detail-variables"
|
||||
/>
|
||||
<UserDateDetail
|
||||
label={actions.created.label}
|
||||
date={inventory.created}
|
||||
user={inventory.summary_fields.created_by}
|
||||
/>
|
||||
<UserDateDetail
|
||||
label={actions.modified.label}
|
||||
date={inventory.modified}
|
||||
user={inventory.summary_fields.modified_by}
|
||||
/>
|
||||
</DetailList>
|
||||
<CardActionsRow>
|
||||
{userCapabilities.edit && (
|
||||
<Button
|
||||
ouiaId="inventory-detail-edit-button"
|
||||
component={Link}
|
||||
to={`/inventories/constructed_inventory/${inventory.id}/edit`}
|
||||
>
|
||||
{t`Edit`}
|
||||
</Button>
|
||||
)}
|
||||
{userCapabilities.delete && (
|
||||
<DeleteButton
|
||||
name={inventory.name}
|
||||
modalTitle={t`Delete Inventory`}
|
||||
onConfirm={deleteInventory}
|
||||
deleteDetailsRequests={deleteDetailsRequests}
|
||||
deleteMessage={t`This inventory is currently being used by other resources. Are you sure you want to delete it?`}
|
||||
>
|
||||
{t`Delete`}
|
||||
</DeleteButton>
|
||||
)}
|
||||
</CardActionsRow>
|
||||
{error && (
|
||||
<AlertModal
|
||||
isOpen={error}
|
||||
variant="error"
|
||||
title={t`Error!`}
|
||||
onClose={dismissError}
|
||||
>
|
||||
{t`Failed to delete inventory.`}
|
||||
<ErrorDetail error={error} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
ConstructedInventoryDetail.propTypes = {
|
||||
inventory: Inventory.isRequired,
|
||||
};
|
||||
|
||||
export default ConstructedInventoryDetail;
|
||||
@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { InventoriesAPI, CredentialTypesAPI } from 'api';
|
||||
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import ConstructedInventoryDetail from './ConstructedInventoryDetail';
|
||||
|
||||
jest.mock('../../../api');
|
||||
|
||||
const mockInventory = {
|
||||
id: 1,
|
||||
type: 'inventory',
|
||||
summary_fields: {
|
||||
organization: {
|
||||
id: 1,
|
||||
name: 'The Organization',
|
||||
description: '',
|
||||
},
|
||||
created_by: {
|
||||
username: 'the_creator',
|
||||
id: 2,
|
||||
},
|
||||
modified_by: {
|
||||
username: 'the_modifier',
|
||||
id: 3,
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
delete: true,
|
||||
copy: true,
|
||||
adhoc: true,
|
||||
},
|
||||
},
|
||||
created: '2019-10-04T16:56:48.025455Z',
|
||||
modified: '2019-10-04T16:56:48.025468Z',
|
||||
name: 'Constructed Inv',
|
||||
description: '',
|
||||
organization: 1,
|
||||
kind: 'constructed',
|
||||
has_active_failures: false,
|
||||
total_hosts: 0,
|
||||
hosts_with_active_failures: 0,
|
||||
total_groups: 0,
|
||||
groups_with_active_failures: 0,
|
||||
has_inventory_sources: false,
|
||||
total_inventory_sources: 0,
|
||||
inventory_sources_with_failures: 0,
|
||||
pending_deletion: false,
|
||||
prevent_instance_group_fallback: false,
|
||||
update_cache_timeout: 0,
|
||||
limit: '',
|
||||
verbosity: 1,
|
||||
};
|
||||
|
||||
describe('<ConstructedInventoryDetail />', () => {
|
||||
test('initially renders successfully', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ConstructedInventoryDetail inventory={mockInventory} />
|
||||
);
|
||||
});
|
||||
expect(wrapper.length).toBe(1);
|
||||
expect(wrapper.find('ConstructedInventoryDetail').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './ConstructedInventoryDetail';
|
||||
@ -0,0 +1,13 @@
|
||||
/* eslint i18next/no-literal-string: "off" */
|
||||
import React from 'react';
|
||||
import { CardBody } from 'components/Card';
|
||||
|
||||
function ConstructedInventoryEdit() {
|
||||
return (
|
||||
<CardBody>
|
||||
<div>Coming Soon!</div>
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConstructedInventoryEdit;
|
||||
@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import ConstructedInventoryEdit from './ConstructedInventoryEdit';
|
||||
|
||||
describe('<ConstructedInventoryEdit />', () => {
|
||||
test('initially renders successfully', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ConstructedInventoryEdit />);
|
||||
});
|
||||
expect(wrapper.length).toBe(1);
|
||||
expect(wrapper.find('ConstructedInventoryEdit').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './ConstructedInventoryEdit';
|
||||
@ -0,0 +1,13 @@
|
||||
/* eslint i18next/no-literal-string: "off" */
|
||||
import React from 'react';
|
||||
import { CardBody } from 'components/Card';
|
||||
|
||||
function ConstructedInventoryGroups() {
|
||||
return (
|
||||
<CardBody>
|
||||
<div>Coming Soon!</div>
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConstructedInventoryGroups;
|
||||
@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import ConstructedInventoryGroups from './ConstructedInventoryGroups';
|
||||
|
||||
describe('<ConstructedInventoryGroups />', () => {
|
||||
test('initially renders successfully', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ConstructedInventoryGroups />);
|
||||
});
|
||||
expect(wrapper.length).toBe(1);
|
||||
expect(wrapper.find('ConstructedInventoryGroups').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './ConstructedInventoryGroups';
|
||||
@ -0,0 +1,13 @@
|
||||
/* eslint i18next/no-literal-string: "off" */
|
||||
import React from 'react';
|
||||
import { CardBody } from 'components/Card';
|
||||
|
||||
function ConstructedInventoryHosts() {
|
||||
return (
|
||||
<CardBody>
|
||||
<div>Coming Soon!</div>
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConstructedInventoryHosts;
|
||||
@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import ConstructedInventoryHosts from './ConstructedInventoryHosts';
|
||||
|
||||
describe('<ConstructedInventoryHosts />', () => {
|
||||
test('initially renders successfully', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ConstructedInventoryHosts />);
|
||||
});
|
||||
expect(wrapper.length).toBe(1);
|
||||
expect(wrapper.find('ConstructedInventoryHosts').length).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './ConstructedInventoryHosts';
|
||||
@ -9,14 +9,18 @@ import PersistentFilters from 'components/PersistentFilters';
|
||||
import { InventoryList } from './InventoryList';
|
||||
import Inventory from './Inventory';
|
||||
import SmartInventory from './SmartInventory';
|
||||
import ConstructedInventory from './ConstructedInventory';
|
||||
import InventoryAdd from './InventoryAdd';
|
||||
import SmartInventoryAdd from './SmartInventoryAdd';
|
||||
import ConstructedInventoryAdd from './ConstructedInventoryAdd';
|
||||
import { getInventoryPath } from './shared/utils';
|
||||
|
||||
function Inventories() {
|
||||
const initScreenHeader = useRef({
|
||||
'/inventories': t`Inventories`,
|
||||
'/inventories/inventory/add': t`Create new inventory`,
|
||||
'/inventories/smart_inventory/add': t`Create new smart inventory`,
|
||||
'/inventories/constructed_inventory/add': t`Create new constructed inventory`,
|
||||
});
|
||||
|
||||
const [breadcrumbConfig, setScreenHeader] = useState(
|
||||
@ -45,10 +49,7 @@ function Inventories() {
|
||||
return;
|
||||
}
|
||||
|
||||
const inventoryKind =
|
||||
inventory.kind === 'smart' ? 'smart_inventory' : 'inventory';
|
||||
|
||||
const inventoryPath = `/inventories/${inventoryKind}/${inventory.id}`;
|
||||
const inventoryPath = getInventoryPath(inventory);
|
||||
const inventoryHostsPath = `${inventoryPath}/hosts`;
|
||||
const inventoryGroupsPath = `${inventoryPath}/groups`;
|
||||
const inventorySourcesPath = `${inventoryPath}/sources`;
|
||||
@ -109,6 +110,9 @@ function Inventories() {
|
||||
<Route path="/inventories/smart_inventory/add">
|
||||
<SmartInventoryAdd />
|
||||
</Route>
|
||||
<Route path="/inventories/constructed_inventory/add">
|
||||
<ConstructedInventoryAdd />
|
||||
</Route>
|
||||
<Route path="/inventories/inventory/:id">
|
||||
<Config>
|
||||
{({ me }) => (
|
||||
@ -119,6 +123,9 @@ function Inventories() {
|
||||
<Route path="/inventories/smart_inventory/:id">
|
||||
<SmartInventory setBreadcrumb={setBreadcrumbConfig} />
|
||||
</Route>
|
||||
<Route path="/inventories/constructed_inventory/:id">
|
||||
<ConstructedInventory setBreadcrumb={setBreadcrumbConfig} />
|
||||
</Route>
|
||||
<Route path="/inventories">
|
||||
<PersistentFilters pageKey="inventories">
|
||||
<InventoryList />
|
||||
|
||||
@ -23,6 +23,7 @@ import InventoryEdit from './InventoryEdit';
|
||||
import InventoryGroups from './InventoryGroups';
|
||||
import InventoryHosts from './InventoryHosts/InventoryHosts';
|
||||
import InventorySources from './InventorySources';
|
||||
import { getInventoryPath } from './shared/utils';
|
||||
|
||||
function Inventory({ setBreadcrumb }) {
|
||||
const [contentError, setContentError] = useState(null);
|
||||
@ -111,10 +112,8 @@ function Inventory({ setBreadcrumb }) {
|
||||
showCardHeader = false;
|
||||
}
|
||||
|
||||
if (inventory?.kind === 'smart') {
|
||||
return (
|
||||
<Redirect to={`/inventories/smart_inventory/${inventory.id}/details`} />
|
||||
);
|
||||
if (inventory && inventory?.kind !== '') {
|
||||
return <Redirect to={`${getInventoryPath(inventory)}/details`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -135,6 +135,7 @@ function InventoryList() {
|
||||
|
||||
const addInventory = t`Add inventory`;
|
||||
const addSmartInventory = t`Add smart inventory`;
|
||||
const addConstructedInventory = t`Add constructed inventory`;
|
||||
const addButton = (
|
||||
<AddDropDownButton
|
||||
ouiaId="add-inventory-button"
|
||||
@ -158,6 +159,15 @@ function InventoryList() {
|
||||
>
|
||||
{addSmartInventory}
|
||||
</DropdownItem>,
|
||||
<DropdownItem
|
||||
ouiaId="add-constructed-inventory-item"
|
||||
to={`${match.url}/constructed_inventory/add/`}
|
||||
component={Link}
|
||||
key={addConstructedInventory}
|
||||
aria-label={addConstructedInventory}
|
||||
>
|
||||
{addConstructedInventory}
|
||||
</DropdownItem>,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
@ -261,11 +271,6 @@ function InventoryList() {
|
||||
inventory={inventory}
|
||||
rowIndex={index}
|
||||
fetchInventories={fetchInventories}
|
||||
detailUrl={
|
||||
inventory.kind === 'smart'
|
||||
? `${match.url}/smart_inventory/${inventory.id}/details`
|
||||
: `${match.url}/inventory/${inventory.id}/details`
|
||||
}
|
||||
onSelect={() => {
|
||||
if (!inventory.pending_deletion) {
|
||||
handleSelect(inventory);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { string, bool, func } from 'prop-types';
|
||||
import { bool, func } from 'prop-types';
|
||||
|
||||
import { Button, Label } from '@patternfly/react-core';
|
||||
import { Tr, Td } from '@patternfly/react-table';
|
||||
@ -12,6 +12,7 @@ import { Inventory } from 'types';
|
||||
import { ActionsTd, ActionItem, TdBreakWord } from 'components/PaginatedTable';
|
||||
import CopyButton from 'components/CopyButton';
|
||||
import StatusLabel from 'components/StatusLabel';
|
||||
import { getInventoryPath } from '../shared/utils';
|
||||
|
||||
function InventoryListItem({
|
||||
inventory,
|
||||
@ -19,12 +20,10 @@ function InventoryListItem({
|
||||
isSelected,
|
||||
onSelect,
|
||||
onCopy,
|
||||
detailUrl,
|
||||
fetchInventories,
|
||||
}) {
|
||||
InventoryListItem.propTypes = {
|
||||
inventory: Inventory.isRequired,
|
||||
detailUrl: string.isRequired,
|
||||
isSelected: bool.isRequired,
|
||||
onSelect: func.isRequired,
|
||||
};
|
||||
@ -50,6 +49,12 @@ function InventoryListItem({
|
||||
|
||||
const labelId = `check-action-${inventory.id}`;
|
||||
|
||||
const typeLabel = {
|
||||
'': t`Inventory`,
|
||||
smart: t`Smart Inventory`,
|
||||
constructed: t`Constructed Inventory`,
|
||||
};
|
||||
|
||||
let syncStatus = 'disabled';
|
||||
if (inventory.isSourceSyncRunning) {
|
||||
syncStatus = 'syncing';
|
||||
@ -93,16 +98,20 @@ function InventoryListItem({
|
||||
{inventory.pending_deletion ? (
|
||||
<b>{inventory.name}</b>
|
||||
) : (
|
||||
<Link to={`${detailUrl}`}>
|
||||
<Link to={`${getInventoryPath(inventory)}/details`}>
|
||||
<b>{inventory.name}</b>
|
||||
</Link>
|
||||
)}
|
||||
</TdBreakWord>
|
||||
<Td dataLabel={t`Status`}>
|
||||
{inventory.kind !== 'smart' &&
|
||||
{inventory.kind === '' &&
|
||||
(inventory.has_inventory_sources ? (
|
||||
<Link
|
||||
to={`/inventories/inventory/${inventory.id}/jobs?job.or__inventoryupdate__inventory_source__inventory__id=${inventory.id}`}
|
||||
to={`${getInventoryPath(
|
||||
inventory
|
||||
)}/jobs?job.or__inventoryupdate__inventory_source__inventory__id=${
|
||||
inventory.id
|
||||
}`}
|
||||
>
|
||||
<StatusLabel
|
||||
status={syncStatus}
|
||||
@ -113,9 +122,7 @@ function InventoryListItem({
|
||||
<StatusLabel status={syncStatus} tooltipContent={tooltipContent} />
|
||||
))}
|
||||
</Td>
|
||||
<Td dataLabel={t`Type`}>
|
||||
{inventory.kind === 'smart' ? t`Smart Inventory` : t`Inventory`}
|
||||
</Td>
|
||||
<Td dataLabel={t`Type`}>{typeLabel[inventory.kind]}</Td>
|
||||
<TdBreakWord key="organization" dataLabel={t`Organization`}>
|
||||
<Link
|
||||
to={`/organizations/${inventory?.summary_fields?.organization?.id}/details`}
|
||||
@ -139,9 +146,7 @@ function InventoryListItem({
|
||||
aria-label={t`Edit Inventory`}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/inventories/${
|
||||
inventory.kind === 'smart' ? 'smart_inventory' : 'inventory'
|
||||
}/${inventory.id}/edit`}
|
||||
to={`${getInventoryPath(inventory)}edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
|
||||
@ -23,6 +23,7 @@ import RelatedTemplateList from 'components/RelatedTemplateList';
|
||||
import SmartInventoryDetail from './SmartInventoryDetail';
|
||||
import SmartInventoryEdit from './SmartInventoryEdit';
|
||||
import SmartInventoryHosts from './SmartInventoryHosts';
|
||||
import { getInventoryPath } from './shared/utils';
|
||||
|
||||
function SmartInventory({ setBreadcrumb }) {
|
||||
const location = useLocation();
|
||||
@ -101,8 +102,8 @@ function SmartInventory({ setBreadcrumb }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (inventory?.kind === '') {
|
||||
return <Redirect to={`/inventories/inventory/${inventory.id}/details`} />;
|
||||
if (inventory && inventory?.kind !== 'smart') {
|
||||
return <Redirect to={`${getInventoryPath(inventory)}/details`} />;
|
||||
}
|
||||
|
||||
let showCardHeader = true;
|
||||
|
||||
@ -8,3 +8,12 @@ const parseHostFilter = (value) => {
|
||||
return value;
|
||||
};
|
||||
export default parseHostFilter;
|
||||
|
||||
export function getInventoryPath(inventory) {
|
||||
const url = {
|
||||
'': `/inventories/inventory/${inventory.id}`,
|
||||
smart: `/inventories/smart_inventory/${inventory.id}`,
|
||||
constructed: `/inventories/constructed_inventory/${inventory.id}`,
|
||||
};
|
||||
return url[inventory.kind];
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import parseHostFilter from './utils';
|
||||
import parseHostFilter, { getInventoryPath } from './utils';
|
||||
|
||||
describe('parseHostFilter', () => {
|
||||
test('parse host filter', () => {
|
||||
@ -19,3 +19,21 @@ describe('parseHostFilter', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInventoryPath', () => {
|
||||
test('should return inventory path', () => {
|
||||
expect(getInventoryPath({ id: 1, kind: '' })).toMatch(
|
||||
'/inventories/inventory/1'
|
||||
);
|
||||
});
|
||||
test('should return smart inventory path', () => {
|
||||
expect(getInventoryPath({ id: 2, kind: 'smart' })).toMatch(
|
||||
'/inventories/smart_inventory/2'
|
||||
);
|
||||
});
|
||||
test('should return constructed inventory path', () => {
|
||||
expect(getInventoryPath({ id: 3, kind: 'constructed' })).toMatch(
|
||||
'/inventories/constructed_inventory/3'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user