Add related job templates to a couple of screens (#11890)

Add related job templates to a couple of screens. Credential and
Inventory.

Also refactor the component already in place for Projects to be in sync
with the Job Templates screen.

See: https://github.com/ansible/awx/issues/5867
This commit is contained in:
Kersom
2022-03-18 16:52:50 -04:00
committed by GitHub
parent 7818a479ee
commit 9aae2a11f2
16 changed files with 441 additions and 431 deletions

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { t } from '@lingui/macro';
import { t, Plural } from '@lingui/macro';
import { Card } from '@patternfly/react-core';
import { JobTemplatesAPI } from 'api';
import AlertModal from 'components/AlertModal';
@@ -14,10 +14,14 @@ import PaginatedTable, {
ToolbarDeleteButton,
getSearchableKeys,
} from 'components/PaginatedTable';
import { getQSConfig, parseQueryString } from 'util/qs';
import { getQSConfig, parseQueryString, mergeParams } from 'util/qs';
import useWsTemplates from 'hooks/useWsTemplates';
import useSelected from 'hooks/useSelected';
import useExpanded from 'hooks/useExpanded';
import useRequest, { useDeleteItems } from 'hooks/useRequest';
import ProjectTemplatesListItem from './ProjectJobTemplatesListItem';
import { TemplateListItem } from 'components/TemplateList';
import useToast, { AlertVariant } from 'hooks/useToast';
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
const QS_CONFIG = getQSConfig('template', {
page: 1,
@@ -25,13 +29,13 @@ const QS_CONFIG = getQSConfig('template', {
order_by: 'name',
});
function ProjectJobTemplatesList() {
const { id: projectId } = useParams();
function RelatedTemplateList({ searchParams }) {
const location = useLocation();
const { addToast, Toast, toastProps } = useToast();
const {
result: {
jobTemplates,
results,
itemCount,
actions,
relatedSearchableKeys,
@@ -43,13 +47,12 @@ function ProjectJobTemplatesList() {
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
params.project = projectId;
const [response, actionsResponse] = await Promise.all([
JobTemplatesAPI.read(params),
JobTemplatesAPI.read(mergeParams(params, searchParams)),
JobTemplatesAPI.readOptions(),
]);
return {
jobTemplates: response.data.results,
results: response.data.results,
itemCount: response.data.count,
actions: actionsResponse.data.actions,
relatedSearchableKeys: (
@@ -57,9 +60,9 @@ function ProjectJobTemplatesList() {
).map((val) => val.slice(0, -8)),
searchableKeys: getSearchableKeys(actionsResponse.data.actions?.GET),
};
}, [location, projectId]),
}, [location]), // eslint-disable-line react-hooks/exhaustive-deps
{
jobTemplates: [],
results: [],
itemCount: 0,
actions: {},
relatedSearchableKeys: [],
@@ -71,9 +74,14 @@ function ProjectJobTemplatesList() {
fetchTemplates();
}, [fetchTemplates]);
const jobTemplates = useWsTemplates(results);
const { selected, isAllSelected, handleSelect, clearSelected, selectAll } =
useSelected(jobTemplates);
const { expanded, isAllExpanded, handleExpand, expandAll } =
useExpanded(jobTemplates);
const {
isLoading: isDeleteLoading,
deleteItems: deleteTemplates,
@@ -94,6 +102,18 @@ function ProjectJobTemplatesList() {
}
);
const handleCopy = useCallback(
(newTemplateId) => {
addToast({
id: newTemplateId,
title: t`Template copied successfully`,
variant: AlertVariant.success,
hasTimeout: true,
});
},
[addToast]
);
const handleTemplateDelete = async () => {
await deleteTemplates();
clearSelected();
@@ -106,6 +126,10 @@ function ProjectJobTemplatesList() {
<ToolbarAddButton key="add" linkTo="/templates/job_template/add/" />
);
const deleteDetailsRequests = relatedResourceDeleteRequests.template(
selected[0]
);
return (
<>
<Card>
@@ -131,14 +155,32 @@ function ProjectJobTemplatesList() {
name: t`Modified By (Username)`,
key: 'modified_by__username__icontains',
},
{
name: t`Playbook name`,
key: 'job_template__playbook__icontains',
},
{
name: t`Label`,
key: 'labels__name__icontains',
},
]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
headerRow={
<HeaderRow qsConfig={QS_CONFIG} isExpandable>
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
<HeaderCell sortKey="type">{t`Type`}</HeaderCell>
<HeaderCell>{t`Recent jobs`}</HeaderCell>
<HeaderCell>{t`Actions`}</HeaderCell>
</HeaderRow>
}
renderToolbar={(props) => (
<DatalistToolbar
{...props}
isAllSelected={isAllSelected}
onSelectAll={selectAll}
isAllExpanded={isAllExpanded}
onExpandAll={expandAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAddJT ? [addButton] : []),
@@ -147,32 +189,37 @@ function ProjectJobTemplatesList() {
onDelete={handleTemplateDelete}
itemsToDelete={selected}
pluralizedItemName={t`Job templates`}
deleteDetailsRequests={deleteDetailsRequests}
deleteMessage={
<Plural
value={selected.length}
one="This template is currently being used by some workflow nodes. Are you sure you want to delete it?"
other="Deleting these templates could impact some workflow nodes that rely on them. Are you sure you want to delete anyway?"
/>
}
/>,
]}
/>
)}
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
<HeaderCell sortKey="type">{t`Type`}</HeaderCell>
<HeaderCell>{t`Recent jobs`}</HeaderCell>
<HeaderCell>{t`Actions`}</HeaderCell>
</HeaderRow>
}
renderRow={(template, index) => (
<ProjectTemplatesListItem
<TemplateListItem
key={template.id}
value={template.name}
template={template}
detailUrl={`/templates/${template.type}/${template.id}/details`}
detailUrl={`/templates/${template.type}/${template.id}`}
onSelect={() => handleSelect(template)}
isExpanded={expanded.some((row) => row.id === template.id)}
onExpand={() => handleExpand(template)}
onCopy={handleCopy}
isSelected={selected.some((row) => row.id === template.id)}
fetchTemplates={fetchTemplates}
rowIndex={index}
/>
)}
emptyStateControls={canAddJT && addButton}
/>
</Card>
<Toast {...toastProps} />
<AlertModal
isOpen={deletionError}
variant="danger"
@@ -186,4 +233,4 @@ function ProjectJobTemplatesList() {
);
}
export default ProjectJobTemplatesList;
export default RelatedTemplateList;

View File

@@ -0,0 +1,276 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { JobTemplatesAPI, UnifiedJobTemplatesAPI } from 'api';
import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import RelatedTemplateList from './RelatedTemplateList';
jest.mock('../../api');
const mockTemplates = [
{
id: 1,
name: 'Job Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
delete: true,
edit: true,
copy: true,
},
},
},
{
id: 2,
name: 'Job Template 2',
url: '/templates/job_template/2',
type: 'job_template',
summary_fields: {
user_capabilities: {
delete: true,
},
},
},
{
id: 3,
name: 'Job Template 3',
url: '/templates/job_template/3',
type: 'job_template',
summary_fields: {
user_capabilities: {
delete: false,
},
},
},
];
describe('<TemplateList />', () => {
let debug;
beforeEach(() => {
JobTemplatesAPI.read.mockResolvedValue({
data: {
count: mockTemplates.length,
results: mockTemplates,
},
});
JobTemplatesAPI.readOptions.mockResolvedValue({
data: {
actions: [],
},
});
debug = global.console.debug; // eslint-disable-line prefer-destructuring
global.console.debug = () => {};
});
afterEach(() => {
jest.clearAllMocks();
global.console.debug = debug;
});
test('Templates are retrieved from the api and the components finishes loading', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<RelatedTemplateList searchParams={{ credentials__id: 1 }} />
);
});
expect(JobTemplatesAPI.read).toBeCalledWith({
credentials__id: 1,
order_by: 'name',
page: 1,
page_size: 20,
});
await act(async () => {
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
});
expect(wrapper.find('TemplateListItem').length).toEqual(
mockTemplates.length
);
});
test('handleSelect is called when a template list item is selected', async () => {
const wrapper = mountWithContexts(
<RelatedTemplateList searchParams={{ credentials__id: 1 }} />
);
await act(async () => {
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
});
const checkBox = wrapper.find('TemplateListItem').at(1).find('input');
checkBox.simulate('change', {
target: {
id: 2,
name: 'Job Template 2',
url: '/templates/job_template/2',
type: 'job_template',
summary_fields: { user_capabilities: { delete: true } },
},
});
expect(wrapper.find('TemplateListItem').at(1).prop('isSelected')).toBe(
true
);
});
test('handleSelectAll is called when a template list item is selected', async () => {
const wrapper = mountWithContexts(
<RelatedTemplateList searchParams={{ credentials__id: 1 }} />
);
await act(async () => {
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
});
expect(wrapper.find('Checkbox#select-all').prop('isChecked')).toBe(false);
const toolBarCheckBox = wrapper.find('Checkbox#select-all');
act(() => {
toolBarCheckBox.prop('onChange')(true);
});
wrapper.update();
expect(wrapper.find('Checkbox#select-all').prop('isChecked')).toBe(true);
});
test('delete button is disabled if user does not have delete capabilities on a selected template', async () => {
const wrapper = mountWithContexts(
<RelatedTemplateList searchParams={{ credentials__id: 1 }} />
);
await act(async () => {
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
});
const deleteAbleItem = wrapper.find('TemplateListItem').at(0).find('input');
const nonDeleteAbleItem = wrapper
.find('TemplateListItem')
.at(2)
.find('input');
deleteAbleItem.simulate('change', {
id: 1,
name: 'Job Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
delete: true,
},
},
});
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
false
);
deleteAbleItem.simulate('change', {
id: 1,
name: 'Job Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
delete: true,
},
},
});
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
true
);
nonDeleteAbleItem.simulate('change', {
id: 5,
name: 'Workflow Job Template 2',
url: '/templates/workflow_job_template/5',
type: 'workflow_job_template',
summary_fields: {
user_capabilities: {
delete: false,
},
},
});
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
true
);
});
test('api is called to delete templates for each selected template.', async () => {
const wrapper = mountWithContexts(
<RelatedTemplateList searchParams={{ credentials__id: 1 }} />
);
await act(async () => {
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
});
const jobTemplate = wrapper.find('TemplateListItem').at(1).find('input');
jobTemplate.simulate('change', {
target: {
id: 2,
name: 'Job Template 2',
url: '/templates/job_template/2',
type: 'job_template',
summary_fields: { user_capabilities: { delete: true } },
},
});
await act(async () => {
wrapper.find('button[aria-label="Delete"]').prop('onClick')();
});
wrapper.update();
await act(async () => {
await wrapper
.find('button[aria-label="confirm delete"]')
.prop('onClick')();
});
expect(JobTemplatesAPI.destroy).toBeCalledWith(2);
});
test('error is shown when template not successfully deleted from api', async () => {
JobTemplatesAPI.destroy.mockRejectedValue(
new Error({
response: {
config: {
method: 'delete',
url: '/api/v2/job_templates/1',
},
data: 'An error occurred',
},
})
);
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<RelatedTemplateList searchParams={{ credentials__id: 1 }} />
);
});
wrapper.update();
expect(JobTemplatesAPI.read).toHaveBeenCalledTimes(1);
await act(async () => {
wrapper.find('TemplateListItem').at(0).invoke('onSelect')();
});
wrapper.update();
await act(async () => {
wrapper.find('ToolbarDeleteButton').invoke('onDelete')();
});
wrapper.update();
const modal = wrapper.find('Modal');
expect(modal).toHaveLength(1);
expect(modal.prop('title')).toEqual('Error!');
});
test('should properly copy template', async () => {
JobTemplatesAPI.copy.mockResolvedValue({});
const wrapper = mountWithContexts(
<RelatedTemplateList searchParams={{ credentials__id: 1 }} />
);
await act(async () => {
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
});
await act(async () =>
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
);
expect(JobTemplatesAPI.copy).toHaveBeenCalled();
});
});

View File

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

View File

@@ -17,6 +17,7 @@ import { ResourceAccessList } from 'components/ResourceAccessList';
import ContentError from 'components/ContentError';
import ContentLoading from 'components/ContentLoading';
import RoutedTabs from 'components/RoutedTabs';
import RelatedTemplateList from 'components/RelatedTemplateList';
import { CredentialsAPI } from 'api';
import CredentialDetail from './CredentialDetail';
import CredentialEdit from './CredentialEdit';
@@ -73,6 +74,11 @@ function Credential({ setBreadcrumb }) {
link: `/credentials/${id}/access`,
id: 1,
},
{
name: t`Job Templates`,
link: `/credentials/${id}/job_templates`,
id: 2,
},
];
let showCardHeader = true;
@@ -123,6 +129,11 @@ function Credential({ setBreadcrumb }) {
apiModel={CredentialsAPI}
/>
</Route>,
<Route key="job_templates" path="/credentials/:id/job_templates">
<RelatedTemplateList
searchParams={{ credentials__id: credential.id }}
/>
</Route>,
<Route key="not-found" path="*">
{!hasContentLoading && (
<ContentError isNotFound>

View File

@@ -7,7 +7,6 @@ import {
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import mockCredential from './shared/data.scmCredential.json';
import mockOrgCredential from './shared/data.orgCredential.json';
import Credential from './Credential';
jest.mock('../../api');
@@ -32,21 +31,24 @@ describe('<Credential />', () => {
await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
await waitForElement(wrapper, '.pf-c-tabs__item', (el) => el.length === 3);
wrapper.update();
expect(wrapper.find('Credential').length).toBe(1);
expect(wrapper.find('RoutedTabs li').length).toBe(4);
});
test('initially renders org-based credential successfully', async () => {
CredentialsAPI.readDetail.mockResolvedValueOnce({
data: mockOrgCredential,
});
test('should render expected tabs', async () => {
const expectedTabs = [
'Back to Credentials',
'Details',
'Access',
'Job Templates',
];
await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
// org-based credential detail needs access tab
await waitForElement(wrapper, '.pf-c-tabs__item', (el) => el.length === 3);
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 () => {

View File

@@ -26,6 +26,7 @@ function Credentials() {
[`/credentials/${credential.id}/edit`]: t`Edit Details`,
[`/credentials/${credential.id}/details`]: t`Details`,
[`/credentials/${credential.id}/access`]: t`Access`,
[`/credentials/${credential.id}/job_templates`]: t`Job Templates`,
});
}, []);

View File

@@ -58,6 +58,7 @@ function Inventories() {
[`${inventoryPath}/access`]: t`Access`,
[`${inventoryPath}/jobs`]: t`Jobs`,
[`${inventoryPath}/details`]: t`Details`,
[`${inventoryPath}/job_templates`]: t`Job Templates`,
[`${inventoryPath}/edit`]: t`Edit details`,
[inventoryHostsPath]: t`Hosts`,

View File

@@ -16,6 +16,7 @@ import ContentLoading from 'components/ContentLoading';
import JobList from 'components/JobList';
import RoutedTabs from 'components/RoutedTabs';
import { ResourceAccessList } from 'components/ResourceAccessList';
import RelatedTemplateList from 'components/RelatedTemplateList';
import { InventoriesAPI } from 'api';
import InventoryDetail from './InventoryDetail';
import InventoryEdit from './InventoryEdit';
@@ -69,6 +70,7 @@ function Inventory({ setBreadcrumb }) {
link: `${match.url}/jobs`,
id: 5,
},
{ name: t`Job Templates`, link: `${match.url}/job_templates`, id: 6 },
];
if (hasContentLoading) {
@@ -172,6 +174,14 @@ function Inventory({ setBreadcrumb }) {
]}
/>
</Route>,
<Route
path="/inventories/inventory/:id/job_templates"
key="job_templates"
>
<RelatedTemplateList
searchParams={{ inventory__id: inventory.id }}
/>
</Route>,
<Route path="*" key="not-found">
<ContentError isNotFound>
{match.params.id && (

View File

@@ -31,8 +31,27 @@ describe('<Inventory />', () => {
await act(async () => {
wrapper = mountWithContexts(<Inventory setBreadcrumb={() => {}} />);
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
await waitForElement(wrapper, '.pf-c-tabs__item', (el) => el.length === 7);
wrapper.update();
expect(wrapper.find('Inventory').length).toBe(1);
expect(wrapper.find('RoutedTabs li').length).toBe(8);
});
test('should render expected tabs', async () => {
const expectedTabs = [
'Back to Inventories',
'Details',
'Access',
'Groups',
'Hosts',
'Jobs',
'Job Templates',
];
await act(async () => {
wrapper = mountWithContexts(<Inventory 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 () => {

View File

@@ -19,6 +19,7 @@ import ContentLoading from 'components/ContentLoading';
import JobList from 'components/JobList';
import { ResourceAccessList } from 'components/ResourceAccessList';
import RoutedTabs from 'components/RoutedTabs';
import RelatedTemplateList from 'components/RelatedTemplateList';
import SmartInventoryDetail from './SmartInventoryDetail';
import SmartInventoryEdit from './SmartInventoryEdit';
import SmartInventoryHosts from './SmartInventoryHosts';
@@ -70,6 +71,7 @@ function SmartInventory({ setBreadcrumb }) {
link: `${match.url}/jobs`,
id: 3,
},
{ name: t`Job Templates`, link: `${match.url}/job_templates`, id: 4 },
];
if (hasContentLoading) {
@@ -155,6 +157,14 @@ function SmartInventory({ setBreadcrumb }) {
}}
/>
</Route>,
<Route
key="job_templates"
path="/inventories/smart_inventory/:id/job_templates"
>
<RelatedTemplateList
searchParams={{ inventory__id: inventory.id }}
/>
</Route>,
<Route key="not-found" path="*">
{!hasContentLoading && (
<ContentError isNotFound>

View File

@@ -32,8 +32,26 @@ describe('<SmartInventory />', () => {
await act(async () => {
wrapper = mountWithContexts(<SmartInventory setBreadcrumb={() => {}} />);
});
await waitForElement(wrapper, 'SmartInventory');
await waitForElement(wrapper, '.pf-c-tabs__item', (el) => el.length === 5);
wrapper.update();
expect(wrapper.find('SmartInventory').length).toBe(1);
expect(wrapper.find('RoutedTabs li').length).toBe(6);
});
test('should render expected tabs', async () => {
const expectedTabs = [
'Back to Inventories',
'Details',
'Access',
'Hosts',
'Jobs',
'Job Templates',
];
await act(async () => {
wrapper = mountWithContexts(<SmartInventory setBreadcrumb={() => {}} />);
});
wrapper.find('RoutedTabs li').forEach((tab, index) => {
expect(tab.text()).toEqual(expectedTabs[index]);
});
});
test('should show content error when api throws an error', async () => {

View File

@@ -19,10 +19,10 @@ import ContentLoading from 'components/ContentLoading';
import NotificationList from 'components/NotificationList';
import { ResourceAccessList } from 'components/ResourceAccessList';
import { Schedules } from 'components/Schedule';
import RelatedTemplateList from 'components/RelatedTemplateList';
import { OrganizationsAPI, ProjectsAPI } from 'api';
import ProjectDetail from './ProjectDetail';
import ProjectEdit from './ProjectEdit';
import ProjectJobTemplatesList from './ProjectJobTemplatesList';
function Project({ setBreadcrumb }) {
const { me = {} } = useConfig();
@@ -102,6 +102,10 @@ function Project({ setBreadcrumb }) {
},
{ name: t`Details`, link: `/projects/${id}/details` },
{ name: t`Access`, link: `/projects/${id}/access` },
{
name: t`Job Templates`,
link: `/projects/${id}/job_templates`,
},
];
if (canSeeNotificationsTab) {
@@ -110,12 +114,6 @@ function Project({ setBreadcrumb }) {
link: `/projects/${id}/notifications`,
});
}
tabsArray.push({
name: t`Job Templates`,
link: `/projects/${id}/job_templates`,
});
if (project?.scm_type) {
tabsArray.push({
name: t`Schedules`,
@@ -176,7 +174,7 @@ function Project({ setBreadcrumb }) {
</Route>
)}
<Route path="/projects/:id/job_templates">
<ProjectJobTemplatesList />
<RelatedTemplateList searchParams={{ project__id: project.id }} />
</Route>
{project?.scm_type && project.scm_type !== '' && (
<Route path="/projects/:id/schedules">

View File

@@ -63,7 +63,7 @@ describe('<Project />', () => {
'.pf-c-tabs__item-text',
(el) => el.length === 6
);
expect(tabs.at(3).text()).toEqual('Notifications');
expect(tabs.at(4).text()).toEqual('Notifications');
});
test('notifications tab hidden with reduced permissions', async () => {

View File

@@ -1,121 +0,0 @@
import 'styled-components/macro';
import React from 'react';
import { Link } from 'react-router-dom';
import { Button, Tooltip } from '@patternfly/react-core';
import { Tr, Td } from '@patternfly/react-table';
import {
ExclamationTriangleIcon,
PencilAltIcon,
RocketIcon,
} from '@patternfly/react-icons';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { ActionsTd, ActionItem } from 'components/PaginatedTable';
import { LaunchButton } from 'components/LaunchButton';
import Sparkline from 'components/Sparkline';
import { toTitleCase } from 'util/strings';
const ExclamationTriangleIconWarning = styled(ExclamationTriangleIcon)`
color: var(--pf-global--warning-color--100);
margin-left: 18px;
`;
function ProjectJobTemplateListItem({
template,
isSelected,
onSelect,
detailUrl,
rowIndex,
}) {
const canLaunch = template.summary_fields.user_capabilities.start;
const missingResourceIcon =
template.type === 'job_template' &&
(!template.summary_fields.project ||
(!template.summary_fields.inventory &&
!template.ask_inventory_on_launch));
const missingExecutionEnvironment =
template.type === 'job_template' &&
template.custom_virtualenv &&
!template.execution_environment;
return (
<Tr
id={`template-row-${template.id}`}
ouiaId={`template-row-${template.id}`}
>
<Td
select={{
rowIndex,
isSelected,
onSelect,
}}
/>
<Td dataLabel={t`Name`}>
<Link to={`${detailUrl}`}>
{template.name}
{missingResourceIcon && (
<Tooltip
content={t`Resources are missing from this template.`}
position="right"
>
<ExclamationTriangleIcon css="color: #c9190b; margin-left: 20px;" />
</Tooltip>
)}
{missingExecutionEnvironment && (
<Tooltip
content={t`Custom virtual environment ${template.custom_virtualenv} must be replaced by an execution environment.`}
position="right"
className="missing-execution-environment"
>
<ExclamationTriangleIconWarning />
</Tooltip>
)}
</Link>
</Td>
<Td dataLabel={t`Type`}>{toTitleCase(template.type)}</Td>
<Td dataLabel={t`Recent jobs`}>
<Sparkline jobs={template.summary_fields.recent_jobs} />
</Td>
<ActionsTd dataLabel={t`Actions`}>
<ActionItem
visible={canLaunch && template.type === 'job_template'}
tooltip={t`Launch Template`}
>
<LaunchButton resource={template}>
{({ handleLaunch, isLaunching }) => (
<Button
ouiaId={`${template.id}-launch-button`}
css="grid-column: 1"
variant="plain"
onClick={handleLaunch}
isDisabled={isLaunching}
>
<RocketIcon />
</Button>
)}
</LaunchButton>
</ActionItem>
<ActionItem
visible={template.summary_fields.user_capabilities.edit}
tooltip={t`Edit Template`}
>
<Button
ouiaId={`${template.id}-edit-button`}
css="grid-column: 2"
variant="plain"
component={Link}
to={`/templates/${template.type}/${template.id}/edit`}
>
<PencilAltIcon />
</Button>
</ActionItem>
</ActionsTd>
</Tr>
);
}
export { ProjectJobTemplateListItem as _ProjectJobTemplateListItem };
export default ProjectJobTemplateListItem;

View File

@@ -1,262 +0,0 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import ProjectJobTemplatesListItem from './ProjectJobTemplatesListItem';
describe('<ProjectJobTemplatesListItem />', () => {
test('launch button shown to users with start capabilities', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<ProjectJobTemplatesListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
start: true,
},
},
}}
/>
</tbody>
</table>
);
expect(wrapper.find('LaunchButton').exists()).toBeTruthy();
});
test('launch button hidden from users without start capabilities', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<ProjectJobTemplatesListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
start: false,
},
},
}}
/>
</tbody>
</table>
);
expect(wrapper.find('LaunchButton').exists()).toBeFalsy();
});
test('edit button shown to users with edit capabilities', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<ProjectJobTemplatesListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
edit: true,
},
},
}}
/>
</tbody>
</table>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
});
test('edit button hidden from users without edit capabilities', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<ProjectJobTemplatesListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
edit: false,
},
},
}}
/>
</tbody>
</table>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});
test('missing resource icon is shown.', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<ProjectJobTemplatesListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
edit: false,
},
},
}}
/>
</tbody>
</table>
);
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(true);
});
test('missing resource icon is not shown when there is a project and an inventory.', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<ProjectJobTemplatesListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
edit: false,
},
project: { name: 'Foo', id: 2 },
inventory: { name: 'Bar', id: 2 },
},
}}
/>
</tbody>
</table>
);
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
});
test('missing resource icon is not shown when inventory is prompt_on_launch, and a project', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<ProjectJobTemplatesListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
ask_inventory_on_launch: true,
summary_fields: {
user_capabilities: {
edit: false,
},
project: { name: 'Foo', id: 2 },
},
}}
/>
</tbody>
</table>
);
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
});
test('missing resource icon is not shown type is workflow_job_template', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<ProjectJobTemplatesListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'workflow_job_template',
summary_fields: {
user_capabilities: {
edit: false,
},
},
}}
/>
</tbody>
</table>
);
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
});
test('clicking on template from project templates list navigates properly', () => {
const history = createMemoryHistory({
initialEntries: ['/projects/1/job_templates'],
});
const wrapper = mountWithContexts(
<table>
<tbody>
<ProjectJobTemplatesListItem
isSelected={false}
detailUrl="/templates/job_template/2/details"
template={{
id: 2,
name: 'Template 2',
summary_fields: {
user_capabilities: {
edit: false,
},
},
}}
/>
</tbody>
</table>,
{ context: { router: { history } } }
);
wrapper.find('Link').simulate('click', { button: 0 });
expect(history.location.pathname).toEqual(
'/templates/job_template/2/details'
);
});
test('should render warning about missing execution environment', () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<ProjectJobTemplatesListItem
isSelected={false}
template={{
id: 1,
name: 'Template 1',
url: '/templates/job_template/1',
type: 'job_template',
summary_fields: {
user_capabilities: {
edit: true,
},
},
custom_virtualenv: '/var/lib/awx/env',
execution_environment: null,
}}
/>
</tbody>
</table>
);
expect(
wrapper.find('.missing-execution-environment').prop('content')
).toEqual(
'Custom virtual environment /var/lib/awx/env must be replaced by an execution environment.'
);
});
});

View File

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