mirror of
https://github.com/ansible/awx.git
synced 2026-01-13 02:50:02 -03:30
add copy of template list for dashboard use, moving around shared ws hook to util
This commit is contained in:
parent
782e8d5875
commit
5ea98ab02a
@ -0,0 +1,280 @@
|
||||
import React, { Fragment, useEffect, useState, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Card } from '@patternfly/react-core';
|
||||
|
||||
import {
|
||||
JobTemplatesAPI,
|
||||
UnifiedJobTemplatesAPI,
|
||||
WorkflowJobTemplatesAPI,
|
||||
} from '../../../api';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import DatalistToolbar from '../../../components/DataListToolbar';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import PaginatedDataList, {
|
||||
ToolbarDeleteButton,
|
||||
} from '../../../components/PaginatedDataList';
|
||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import useWsTemplates from '../../../util/useWsTemplates';
|
||||
import AddDropDownButton from '../../../components/AddDropDownButton';
|
||||
|
||||
import DashboardTemplateListItem from './DashboardTemplateListItem';
|
||||
|
||||
const QS_CONFIG = getQSConfig(
|
||||
'template',
|
||||
{
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
type: 'job_template,workflow_job_template',
|
||||
},
|
||||
['id', 'page', 'page_size']
|
||||
);
|
||||
|
||||
function DashboardTemplateList({ i18n }) {
|
||||
// The type value in const QS_CONFIG below does not have a space between job_template and
|
||||
// workflow_job_template so the params sent to the API match what the api expects.
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const [selected, setSelected] = useState([]);
|
||||
|
||||
const {
|
||||
result: {
|
||||
results,
|
||||
count,
|
||||
jtActions,
|
||||
wfjtActions,
|
||||
relatedSearchableKeys,
|
||||
searchableKeys,
|
||||
},
|
||||
error: contentError,
|
||||
isLoading,
|
||||
request: fetchTemplates,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const responses = await Promise.all([
|
||||
UnifiedJobTemplatesAPI.read(params),
|
||||
JobTemplatesAPI.readOptions(),
|
||||
WorkflowJobTemplatesAPI.readOptions(),
|
||||
UnifiedJobTemplatesAPI.readOptions(),
|
||||
]);
|
||||
return {
|
||||
results: responses[0].data.results,
|
||||
count: responses[0].data.count,
|
||||
jtActions: responses[1].data.actions,
|
||||
wfjtActions: responses[2].data.actions,
|
||||
relatedSearchableKeys: (
|
||||
responses[3]?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
responses[3].data.actions?.GET || {}
|
||||
).filter(key => responses[3].data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [location]),
|
||||
{
|
||||
results: [],
|
||||
count: 0,
|
||||
jtActions: {},
|
||||
wfjtActions: {},
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, [fetchTemplates]);
|
||||
|
||||
const templates = useWsTemplates(results);
|
||||
|
||||
const isAllSelected =
|
||||
selected.length === templates.length && selected.length > 0;
|
||||
const {
|
||||
isLoading: isDeleteLoading,
|
||||
deleteItems: deleteTemplates,
|
||||
deletionError,
|
||||
clearDeletionError,
|
||||
} = useDeleteItems(
|
||||
useCallback(async () => {
|
||||
return Promise.all(
|
||||
selected.map(({ type, id }) => {
|
||||
if (type === 'job_template') {
|
||||
return JobTemplatesAPI.destroy(id);
|
||||
}
|
||||
if (type === 'workflow_job_template') {
|
||||
return WorkflowJobTemplatesAPI.destroy(id);
|
||||
}
|
||||
return false;
|
||||
})
|
||||
);
|
||||
}, [selected]),
|
||||
{
|
||||
qsConfig: QS_CONFIG,
|
||||
allItemsSelected: isAllSelected,
|
||||
fetchItems: fetchTemplates,
|
||||
}
|
||||
);
|
||||
|
||||
const handleTemplateDelete = async () => {
|
||||
await deleteTemplates();
|
||||
setSelected([]);
|
||||
};
|
||||
|
||||
const handleSelectAll = isSelected => {
|
||||
setSelected(isSelected ? [...templates] : []);
|
||||
};
|
||||
|
||||
const handleSelect = template => {
|
||||
if (selected.some(s => s.id === template.id)) {
|
||||
setSelected(selected.filter(s => s.id !== template.id));
|
||||
} else {
|
||||
setSelected(selected.concat(template));
|
||||
}
|
||||
};
|
||||
|
||||
const canAddJT =
|
||||
jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST');
|
||||
const canAddWFJT =
|
||||
wfjtActions && Object.prototype.hasOwnProperty.call(wfjtActions, 'POST');
|
||||
// spreading Set() returns only unique keys
|
||||
const addButtonOptions = [];
|
||||
|
||||
if (canAddJT) {
|
||||
addButtonOptions.push({
|
||||
label: i18n._(t`Job Template`),
|
||||
url: `/templates/job_template/add/`,
|
||||
});
|
||||
}
|
||||
|
||||
if (canAddWFJT) {
|
||||
addButtonOptions.push({
|
||||
label: i18n._(t`Workflow Template`),
|
||||
url: `/templates/workflow_job_template/add/`,
|
||||
});
|
||||
}
|
||||
|
||||
const addButton = (
|
||||
<AddDropDownButton key="add" dropdownItems={addButtonOptions} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Card>
|
||||
<PaginatedDataList
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || isDeleteLoading}
|
||||
items={templates}
|
||||
itemCount={count}
|
||||
pluralizedItemName={i18n._(t`Templates`)}
|
||||
qsConfig={QS_CONFIG}
|
||||
onRowClick={handleSelect}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name__icontains',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Description`),
|
||||
key: 'description__icontains',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Type`),
|
||||
key: 'or__type',
|
||||
options: [
|
||||
[`job_template`, i18n._(t`Job Template`)],
|
||||
[`workflow_job_template`, i18n._(t`Workflow Template`)],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Playbook name`),
|
||||
key: 'job_template__playbook__icontains',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username__icontains',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username__icontains',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Inventory`),
|
||||
key: 'job_template__inventory__id',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Last Job Run`),
|
||||
key: 'last_job_run',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified`),
|
||||
key: 'modified',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Project`),
|
||||
key: 'jobtemplate__project__id',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Type`),
|
||||
key: 'type',
|
||||
},
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
renderToolbar={props => (
|
||||
<DatalistToolbar
|
||||
{...props}
|
||||
showSelectAll
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={handleSelectAll}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
...(canAddJT || canAddWFJT ? [addButton] : []),
|
||||
<ToolbarDeleteButton
|
||||
key="delete"
|
||||
onDelete={handleTemplateDelete}
|
||||
itemsToDelete={selected}
|
||||
pluralizedItemName="Templates"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
renderItem={template => (
|
||||
<DashboardTemplateListItem
|
||||
key={template.id}
|
||||
value={template.name}
|
||||
template={template}
|
||||
detailUrl={`/templates/${template.type}/${template.id}`}
|
||||
onSelect={() => handleSelect(template)}
|
||||
isSelected={selected.some(row => row.id === template.id)}
|
||||
fetchTemplates={fetchTemplates}
|
||||
/>
|
||||
)}
|
||||
emptyStateControls={(canAddJT || canAddWFJT) && addButton}
|
||||
/>
|
||||
</Card>
|
||||
<AlertModal
|
||||
aria-label={i18n._(t`Deletion Error`)}
|
||||
isOpen={deletionError}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={clearDeletionError}
|
||||
>
|
||||
{i18n._(t`Failed to delete one or more templates.`)}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(DashboardTemplateList);
|
||||
@ -0,0 +1,336 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
JobTemplatesAPI,
|
||||
UnifiedJobTemplatesAPI,
|
||||
WorkflowJobTemplatesAPI,
|
||||
} from '../../../api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
|
||||
import DashboardTemplateList from './DashboardTemplateList';
|
||||
|
||||
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: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Workflow Job Template 1',
|
||||
url: '/templates/workflow_job_template/4',
|
||||
type: 'workflow_job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Workflow Job Template 2',
|
||||
url: '/templates/workflow_job_template/5',
|
||||
type: 'workflow_job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('<DashboardTemplateList />', () => {
|
||||
let debug;
|
||||
beforeEach(() => {
|
||||
UnifiedJobTemplatesAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: mockTemplates.length,
|
||||
results: mockTemplates,
|
||||
},
|
||||
});
|
||||
|
||||
UnifiedJobTemplatesAPI.readOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: [],
|
||||
},
|
||||
});
|
||||
debug = global.console.debug; // eslint-disable-line prefer-destructuring
|
||||
global.console.debug = () => {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
global.console.debug = debug;
|
||||
});
|
||||
|
||||
test('initially renders successfully', async () => {
|
||||
await act(async () => {
|
||||
mountWithContexts(
|
||||
<DashboardTemplateList
|
||||
match={{ path: '/templates', url: '/templates' }}
|
||||
location={{ search: '', pathname: '/templates' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('Templates are retrieved from the api and the components finishes loading', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<DashboardTemplateList />);
|
||||
});
|
||||
expect(UnifiedJobTemplatesAPI.read).toBeCalled();
|
||||
await act(async () => {
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
expect(wrapper.find('DashboardTemplateListItem').length).toEqual(5);
|
||||
});
|
||||
|
||||
test('handleSelect is called when a template list item is selected', async () => {
|
||||
const wrapper = mountWithContexts(<DashboardTemplateList />);
|
||||
await act(async () => {
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
const checkBox = wrapper
|
||||
.find('DashboardTemplateListItem')
|
||||
.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('DashboardTemplateListItem')
|
||||
.at(1)
|
||||
.prop('isSelected')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('handleSelectAll is called when a template list item is selected', async () => {
|
||||
const wrapper = mountWithContexts(<DashboardTemplateList />);
|
||||
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(<DashboardTemplateList />);
|
||||
await act(async () => {
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
const deleteableItem = wrapper
|
||||
.find('DashboardTemplateListItem')
|
||||
.at(0)
|
||||
.find('input');
|
||||
const nonDeleteableItem = wrapper
|
||||
.find('DashboardTemplateListItem')
|
||||
.at(4)
|
||||
.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(<DashboardTemplateList />);
|
||||
await act(async () => {
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
const jobTemplate = wrapper
|
||||
.find('DashboardTemplateListItem')
|
||||
.at(1)
|
||||
.find('input');
|
||||
const workflowJobTemplate = wrapper
|
||||
.find('DashboardTemplateListItem')
|
||||
.at(3)
|
||||
.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 } },
|
||||
},
|
||||
});
|
||||
|
||||
workflowJobTemplate.simulate('change', {
|
||||
target: {
|
||||
id: 4,
|
||||
name: 'Workflow Job Template 1',
|
||||
url: '/templates/workflow_job_template/4',
|
||||
type: 'workflow_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);
|
||||
expect(WorkflowJobTemplatesAPI.destroy).toBeCalledWith(4);
|
||||
});
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
);
|
||||
const wrapper = mountWithContexts(<DashboardTemplateList />);
|
||||
await act(async () => {
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
const checkBox = wrapper
|
||||
.find('DashboardTemplateListItem')
|
||||
.at(1)
|
||||
.find('input');
|
||||
|
||||
checkBox.simulate('change', {
|
||||
target: {
|
||||
id: 'a',
|
||||
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')();
|
||||
});
|
||||
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'Modal[aria-label="Deletion Error"]',
|
||||
el => el.props().isOpen === true && el.props().title === 'Error!'
|
||||
);
|
||||
});
|
||||
test('should properly copy template', async () => {
|
||||
JobTemplatesAPI.copy.mockResolvedValue({});
|
||||
const wrapper = mountWithContexts(<DashboardTemplateList />);
|
||||
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();
|
||||
expect(UnifiedJobTemplatesAPI.read).toHaveBeenCalled();
|
||||
wrapper.update();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,179 @@
|
||||
import 'styled-components/macro';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
DataListAction as _DataListAction,
|
||||
DataListCheck,
|
||||
DataListItem,
|
||||
DataListItemRow,
|
||||
DataListItemCells,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { t } from '@lingui/macro';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
PencilAltIcon,
|
||||
ProjectDiagramIcon,
|
||||
RocketIcon,
|
||||
} from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import DataListCell from '../../../components/DataListCell';
|
||||
import { timeOfDay } from '../../../util/dates';
|
||||
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../api';
|
||||
import LaunchButton from '../../../components/LaunchButton';
|
||||
import Sparkline from '../../../components/Sparkline';
|
||||
import { toTitleCase } from '../../../util/strings';
|
||||
import CopyButton from '../../../components/CopyButton';
|
||||
|
||||
const DataListAction = styled(_DataListAction)`
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: repeat(4, 40px);
|
||||
`;
|
||||
|
||||
function DashboardTemplateListItem({
|
||||
i18n,
|
||||
template,
|
||||
isSelected,
|
||||
onSelect,
|
||||
detailUrl,
|
||||
fetchTemplates,
|
||||
}) {
|
||||
const [isDisabled, setIsDisabled] = useState(false);
|
||||
const labelId = `check-action-${template.id}`;
|
||||
|
||||
const copyTemplate = useCallback(async () => {
|
||||
if (template.type === 'job_template') {
|
||||
await JobTemplatesAPI.copy(template.id, {
|
||||
name: `${template.name} @ ${timeOfDay()}`,
|
||||
});
|
||||
} else {
|
||||
await WorkflowJobTemplatesAPI.copy(template.id, {
|
||||
name: `${template.name} @ ${timeOfDay()}`,
|
||||
});
|
||||
}
|
||||
await fetchTemplates();
|
||||
}, [fetchTemplates, template.id, template.name, template.type]);
|
||||
|
||||
const handleCopyStart = useCallback(() => {
|
||||
setIsDisabled(true);
|
||||
}, []);
|
||||
|
||||
const handleCopyFinish = useCallback(() => {
|
||||
setIsDisabled(false);
|
||||
}, []);
|
||||
|
||||
const missingResourceIcon =
|
||||
template.type === 'job_template' &&
|
||||
(!template.summary_fields.project ||
|
||||
(!template.summary_fields.inventory &&
|
||||
!template.ask_inventory_on_launch));
|
||||
return (
|
||||
<DataListItem aria-labelledby={labelId} id={`${template.id}`}>
|
||||
<DataListItemRow>
|
||||
<DataListCheck
|
||||
isDisabled={isDisabled}
|
||||
id={`select-jobTemplate-${template.id}`}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key="name" id={labelId}>
|
||||
<span>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{template.name}</b>
|
||||
</Link>
|
||||
</span>
|
||||
{missingResourceIcon && (
|
||||
<span>
|
||||
<Tooltip
|
||||
content={i18n._(
|
||||
t`Resources are missing from this template.`
|
||||
)}
|
||||
position="right"
|
||||
>
|
||||
<ExclamationTriangleIcon css="color: #c9190b; margin-left: 20px;" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
</DataListCell>,
|
||||
<DataListCell key="type">
|
||||
{toTitleCase(template.type)}
|
||||
</DataListCell>,
|
||||
<DataListCell key="sparkline">
|
||||
<Sparkline jobs={template.summary_fields.recent_jobs} />
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction aria-label="actions" aria-labelledby={labelId}>
|
||||
{template.type === 'workflow_job_template' && (
|
||||
<Tooltip content={i18n._(t`Visualizer`)} position="top">
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
aria-label={i18n._(t`Visualizer`)}
|
||||
css="grid-column: 1"
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/templates/workflow_job_template/${template.id}/visualizer`}
|
||||
>
|
||||
<ProjectDiagramIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{template.summary_fields.user_capabilities.start && (
|
||||
<Tooltip content={i18n._(t`Launch Template`)} position="top">
|
||||
<LaunchButton resource={template}>
|
||||
{({ handleLaunch }) => (
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
aria-label={i18n._(t`Launch template`)}
|
||||
css="grid-column: 2"
|
||||
variant="plain"
|
||||
onClick={handleLaunch}
|
||||
>
|
||||
<RocketIcon />
|
||||
</Button>
|
||||
)}
|
||||
</LaunchButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{template.summary_fields.user_capabilities.edit && (
|
||||
<Tooltip content={i18n._(t`Edit Template`)} position="top">
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
aria-label={i18n._(t`Edit Template`)}
|
||||
css="grid-column: 3"
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/templates/${template.type}/${template.id}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{template.summary_fields.user_capabilities.copy && (
|
||||
<CopyButton
|
||||
helperText={{
|
||||
tooltip: i18n._(t`Copy Template`),
|
||||
errorMessage: i18n._(t`Failed to copy template.`),
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
onCopyStart={handleCopyStart}
|
||||
onCopyFinish={handleCopyFinish}
|
||||
copyItem={copyTemplate}
|
||||
/>
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
);
|
||||
}
|
||||
|
||||
export { DashboardTemplateListItem as _TemplateListItem };
|
||||
export default withI18n()(DashboardTemplateListItem);
|
||||
@ -0,0 +1,268 @@
|
||||
import React from 'react';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { JobTemplatesAPI } from '../../../api';
|
||||
|
||||
import mockJobTemplateData from './data.job_template.json';
|
||||
import DashboardTemplateListItem from './DashboardTemplateListItem';
|
||||
|
||||
jest.mock('../../../api');
|
||||
|
||||
describe('<DashboardTemplateListItem />', () => {
|
||||
test('launch button shown to users with start capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
start: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('LaunchButton').exists()).toBeTruthy();
|
||||
});
|
||||
test('launch button hidden from users without start capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
start: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('LaunchButton').exists()).toBeFalsy();
|
||||
});
|
||||
test('edit button shown to users with edit capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
||||
});
|
||||
test('edit button hidden from users without edit capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
||||
});
|
||||
test('missing resource icon is shown.', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<DashboardTemplateListItem
|
||||
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 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<DashboardTemplateListItem
|
||||
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 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
|
||||
});
|
||||
test('missing resource icon is not shown type is workflow_job_template', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'workflow_job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
|
||||
});
|
||||
test('clicking on template from templates list navigates properly', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/templates'],
|
||||
});
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ context: { router: { history } } }
|
||||
);
|
||||
wrapper.find('Link').simulate('click', { button: 0 });
|
||||
expect(history.location.pathname).toEqual(
|
||||
'/templates/job_template/1/details'
|
||||
);
|
||||
});
|
||||
test('should call api to copy template', async () => {
|
||||
JobTemplatesAPI.copy.mockResolvedValue();
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={mockJobTemplateData}
|
||||
/>
|
||||
);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
||||
);
|
||||
expect(JobTemplatesAPI.copy).toHaveBeenCalled();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render proper alert modal on copy error', async () => {
|
||||
JobTemplatesAPI.copy.mockRejectedValue(new Error());
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={mockJobTemplateData}
|
||||
/>
|
||||
);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('Modal').prop('isOpen')).toBe(true);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should not render copy button', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={{
|
||||
...mockJobTemplateData,
|
||||
summary_fields: { user_capabilities: { copy: false } },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('CopyButton').length).toBe(0);
|
||||
});
|
||||
|
||||
test('should render visualizer button for workflow', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={{
|
||||
...mockJobTemplateData,
|
||||
type: 'workflow_job_template',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('ProjectDiagramIcon').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should not render visualizer button for job template', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={mockJobTemplateData}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('ProjectDiagramIcon').length).toBe(0);
|
||||
});
|
||||
});
|
||||
@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { Fragment, useEffect, useState, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
import { Card } from '@patternfly/react-core';
|
||||
|
||||
import {
|
||||
JobTemplatesAPI,
|
||||
@ -17,7 +17,7 @@ import PaginatedDataList, {
|
||||
} from '../../../components/PaginatedDataList';
|
||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import useWsTemplates from './useWsTemplates';
|
||||
import useWsTemplates from '../../../util/useWsTemplates';
|
||||
import AddDropDownButton from '../../../components/AddDropDownButton';
|
||||
import TemplateListItem from './TemplateListItem';
|
||||
|
||||
@ -156,7 +156,7 @@ function TemplateList({ i18n }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<Fragment>
|
||||
<Card>
|
||||
<PaginatedDataList
|
||||
contentError={contentError}
|
||||
@ -267,7 +267,7 @@ function TemplateList({ i18n }) {
|
||||
{i18n._(t`Failed to delete one or more templates.`)}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
</PageSection>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import React, { Component } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Route, withRouter, Switch } from 'react-router-dom';
|
||||
import { PageSection } from '@patternfly/react-core';
|
||||
|
||||
import { Config } from '../../contexts/Config';
|
||||
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
|
||||
@ -120,7 +121,9 @@ class Templates extends Component {
|
||||
)}
|
||||
/>
|
||||
<Route path={`${match.path}`}>
|
||||
<TemplateList />
|
||||
<PageSection>
|
||||
<TemplateList />
|
||||
</PageSection>
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import useWebsocket from '../../../util/useWebsocket';
|
||||
import useWebsocket from './useWebsocket';
|
||||
|
||||
export default function useWsTemplates(initialTemplates) {
|
||||
const [templates, setTemplates] = useState(initialTemplates);
|
||||
@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import WS from 'jest-websocket-mock';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { mountWithContexts } from '../../testUtils/enzymeHelpers';
|
||||
import useWsTemplates from './useWsTemplates';
|
||||
|
||||
/*
|
||||
Jest mock timers don’t play well with jest-websocket-mock,
|
||||
so we'll stub out throttling to resolve immediately
|
||||
*/
|
||||
jest.mock('../../../util/useThrottle', () => ({
|
||||
jest.mock('./useThrottle', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(val => val),
|
||||
}));
|
||||
Loading…
x
Reference in New Issue
Block a user