mirror of
https://github.com/ansible/awx.git
synced 2026-02-26 23:46:05 -03:30
Merge pull request #9114 from keithjgrant/6189-template-list-tables
Convert TemplateList to PaginatedTable Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
@@ -10,12 +10,13 @@ import AlertModal from '../AlertModal';
|
|||||||
import ErrorDetail from '../ErrorDetail';
|
import ErrorDetail from '../ErrorDetail';
|
||||||
|
|
||||||
function CopyButton({
|
function CopyButton({
|
||||||
i18n,
|
id,
|
||||||
copyItem,
|
copyItem,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
onCopyStart,
|
onCopyStart,
|
||||||
onCopyFinish,
|
onCopyFinish,
|
||||||
helperText,
|
helperText,
|
||||||
|
i18n,
|
||||||
}) {
|
}) {
|
||||||
const { isLoading, error: copyError, request: copyItemToAPI } = useRequest(
|
const { isLoading, error: copyError, request: copyItemToAPI } = useRequest(
|
||||||
copyItem
|
copyItem
|
||||||
@@ -34,6 +35,7 @@ function CopyButton({
|
|||||||
<>
|
<>
|
||||||
<Tooltip content={helperText.tooltip} position="top">
|
<Tooltip content={helperText.tooltip} position="top">
|
||||||
<Button
|
<Button
|
||||||
|
id={id}
|
||||||
isDisabled={isLoading || isDisabled}
|
isDisabled={isLoading || isDisabled}
|
||||||
aria-label={i18n._(t`Copy`)}
|
aria-label={i18n._(t`Copy`)}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
|||||||
<HeaderCell sortKey="finished">
|
<HeaderCell sortKey="finished">
|
||||||
{i18n._(t`Finish Time`)}
|
{i18n._(t`Finish Time`)}
|
||||||
</HeaderCell>
|
</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
||||||
</HeaderRow>
|
</HeaderRow>
|
||||||
}
|
}
|
||||||
toolbarSearchableKeys={searchableKeys}
|
toolbarSearchableKeys={searchableKeys}
|
||||||
|
|||||||
@@ -7,29 +7,33 @@ import {
|
|||||||
JobTemplatesAPI,
|
JobTemplatesAPI,
|
||||||
UnifiedJobTemplatesAPI,
|
UnifiedJobTemplatesAPI,
|
||||||
WorkflowJobTemplatesAPI,
|
WorkflowJobTemplatesAPI,
|
||||||
} from '../../../api';
|
} from '../../api';
|
||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../AlertModal';
|
||||||
import DatalistToolbar from '../../../components/DataListToolbar';
|
import DatalistToolbar from '../DataListToolbar';
|
||||||
import ErrorDetail from '../../../components/ErrorDetail';
|
import ErrorDetail from '../ErrorDetail';
|
||||||
import PaginatedDataList, {
|
import { ToolbarDeleteButton } from '../PaginatedDataList';
|
||||||
ToolbarDeleteButton,
|
import PaginatedTable, { HeaderRow, HeaderCell } from '../PaginatedTable';
|
||||||
} from '../../../components/PaginatedDataList';
|
import useRequest, { useDeleteItems } from '../../util/useRequest';
|
||||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
import useWsTemplates from '../../util/useWsTemplates';
|
||||||
import useWsTemplates from '../../../util/useWsTemplates';
|
import AddDropDownButton from '../AddDropDownButton';
|
||||||
import AddDropDownButton from '../../../components/AddDropDownButton';
|
|
||||||
import TemplateListItem from './TemplateListItem';
|
import TemplateListItem from './TemplateListItem';
|
||||||
|
|
||||||
// The type value in const QS_CONFIG below does not have a space between job_template and
|
function TemplateList({ defaultParams, i18n }) {
|
||||||
// workflow_job_template so the params sent to the API match what the api expects.
|
// The type value in const qsConfig below does not have a space between job_template and
|
||||||
const QS_CONFIG = getQSConfig('template', {
|
// workflow_job_template so the params sent to the API match what the api expects.
|
||||||
page: 1,
|
const qsConfig = getQSConfig(
|
||||||
page_size: 20,
|
'template',
|
||||||
order_by: 'name',
|
{
|
||||||
type: 'job_template,workflow_job_template',
|
page: 1,
|
||||||
});
|
page_size: 20,
|
||||||
|
order_by: 'name',
|
||||||
|
type: 'job_template,workflow_job_template',
|
||||||
|
...defaultParams,
|
||||||
|
},
|
||||||
|
['id', 'page', 'page_size']
|
||||||
|
);
|
||||||
|
|
||||||
function TemplateList({ i18n }) {
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [selected, setSelected] = useState([]);
|
const [selected, setSelected] = useState([]);
|
||||||
|
|
||||||
@@ -47,7 +51,7 @@ function TemplateList({ i18n }) {
|
|||||||
request: fetchTemplates,
|
request: fetchTemplates,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const params = parseQueryString(QS_CONFIG, location.search);
|
const params = parseQueryString(qsConfig, location.search);
|
||||||
const responses = await Promise.all([
|
const responses = await Promise.all([
|
||||||
UnifiedJobTemplatesAPI.read(params),
|
UnifiedJobTemplatesAPI.read(params),
|
||||||
JobTemplatesAPI.readOptions(),
|
JobTemplatesAPI.readOptions(),
|
||||||
@@ -66,7 +70,7 @@ function TemplateList({ i18n }) {
|
|||||||
responses[3].data.actions?.GET || {}
|
responses[3].data.actions?.GET || {}
|
||||||
).filter(key => responses[3].data.actions?.GET[key].filterable),
|
).filter(key => responses[3].data.actions?.GET[key].filterable),
|
||||||
};
|
};
|
||||||
}, [location]),
|
}, [location]), // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
{
|
{
|
||||||
results: [],
|
results: [],
|
||||||
count: 0,
|
count: 0,
|
||||||
@@ -105,7 +109,7 @@ function TemplateList({ i18n }) {
|
|||||||
);
|
);
|
||||||
}, [selected]),
|
}, [selected]),
|
||||||
{
|
{
|
||||||
qsConfig: QS_CONFIG,
|
qsConfig,
|
||||||
allItemsSelected: isAllSelected,
|
allItemsSelected: isAllSelected,
|
||||||
fetchItems: fetchTemplates,
|
fetchItems: fetchTemplates,
|
||||||
}
|
}
|
||||||
@@ -167,13 +171,13 @@ function TemplateList({ i18n }) {
|
|||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Card>
|
<Card>
|
||||||
<PaginatedDataList
|
<PaginatedTable
|
||||||
contentError={contentError}
|
contentError={contentError}
|
||||||
hasContentLoading={isLoading || isDeleteLoading}
|
hasContentLoading={isLoading || isDeleteLoading}
|
||||||
items={templates}
|
items={templates}
|
||||||
itemCount={count}
|
itemCount={count}
|
||||||
pluralizedItemName={i18n._(t`Templates`)}
|
pluralizedItemName={i18n._(t`Templates`)}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={qsConfig}
|
||||||
onRowClick={handleSelect}
|
onRowClick={handleSelect}
|
||||||
toolbarSearchColumns={[
|
toolbarSearchColumns={[
|
||||||
{
|
{
|
||||||
@@ -206,41 +210,25 @@ function TemplateList({ i18n }) {
|
|||||||
key: 'modified_by__username__icontains',
|
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}
|
toolbarSearchableKeys={searchableKeys}
|
||||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||||
|
headerRow={
|
||||||
|
<HeaderRow qsConfig={qsConfig} isExpandable>
|
||||||
|
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
|
||||||
|
<HeaderCell sortKey="type">{i18n._(t`Type`)}</HeaderCell>
|
||||||
|
<HeaderCell sortKey="last_job_run">
|
||||||
|
{i18n._(t`Last Ran`)}
|
||||||
|
</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
}
|
||||||
renderToolbar={props => (
|
renderToolbar={props => (
|
||||||
<DatalistToolbar
|
<DatalistToolbar
|
||||||
{...props}
|
{...props}
|
||||||
showSelectAll
|
showSelectAll
|
||||||
isAllSelected={isAllSelected}
|
isAllSelected={isAllSelected}
|
||||||
onSelectAll={handleSelectAll}
|
onSelectAll={handleSelectAll}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={qsConfig}
|
||||||
additionalControls={[
|
additionalControls={[
|
||||||
...(canAddJT || canAddWFJT ? [addButton] : []),
|
...(canAddJT || canAddWFJT ? [addButton] : []),
|
||||||
<ToolbarDeleteButton
|
<ToolbarDeleteButton
|
||||||
@@ -252,7 +240,7 @@ function TemplateList({ i18n }) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderItem={template => (
|
renderRow={(template, index) => (
|
||||||
<TemplateListItem
|
<TemplateListItem
|
||||||
key={template.id}
|
key={template.id}
|
||||||
value={template.name}
|
value={template.name}
|
||||||
@@ -261,6 +249,7 @@ function TemplateList({ i18n }) {
|
|||||||
onSelect={() => handleSelect(template)}
|
onSelect={() => handleSelect(template)}
|
||||||
isSelected={selected.some(row => row.id === template.id)}
|
isSelected={selected.some(row => row.id === template.id)}
|
||||||
fetchTemplates={fetchTemplates}
|
fetchTemplates={fetchTemplates}
|
||||||
|
rowIndex={index}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
emptyStateControls={(canAddJT || canAddWFJT) && addButton}
|
emptyStateControls={(canAddJT || canAddWFJT) && addButton}
|
||||||
@@ -4,15 +4,15 @@ import {
|
|||||||
JobTemplatesAPI,
|
JobTemplatesAPI,
|
||||||
UnifiedJobTemplatesAPI,
|
UnifiedJobTemplatesAPI,
|
||||||
WorkflowJobTemplatesAPI,
|
WorkflowJobTemplatesAPI,
|
||||||
} from '../../../api';
|
} from '../../api';
|
||||||
import {
|
import {
|
||||||
mountWithContexts,
|
mountWithContexts,
|
||||||
waitForElement,
|
waitForElement,
|
||||||
} from '../../../../testUtils/enzymeHelpers';
|
} from '../../../testUtils/enzymeHelpers';
|
||||||
|
|
||||||
import TemplateList from './TemplateList';
|
import TemplateList from './TemplateList';
|
||||||
|
|
||||||
jest.mock('../../../api');
|
jest.mock('../../api');
|
||||||
|
|
||||||
const mockTemplates = [
|
const mockTemplates = [
|
||||||
{
|
{
|
||||||
278
awx/ui_next/src/components/TemplateList/TemplateListItem.jsx
Normal file
278
awx/ui_next/src/components/TemplateList/TemplateListItem.jsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import 'styled-components/macro';
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Button, Tooltip, Chip } from '@patternfly/react-core';
|
||||||
|
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import {
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
PencilAltIcon,
|
||||||
|
ProjectDiagramIcon,
|
||||||
|
RocketIcon,
|
||||||
|
} from '@patternfly/react-icons';
|
||||||
|
import { ActionsTd, ActionItem } from '../PaginatedTable';
|
||||||
|
import { DetailList, Detail, DeletedDetail } from '../DetailList';
|
||||||
|
import ChipGroup from '../ChipGroup';
|
||||||
|
import CredentialChip from '../CredentialChip';
|
||||||
|
import { timeOfDay, formatDateString } from '../../util/dates';
|
||||||
|
|
||||||
|
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api';
|
||||||
|
import LaunchButton from '../LaunchButton';
|
||||||
|
import Sparkline from '../Sparkline';
|
||||||
|
import { toTitleCase } from '../../util/strings';
|
||||||
|
import CopyButton from '../CopyButton';
|
||||||
|
|
||||||
|
function TemplateListItem({
|
||||||
|
i18n,
|
||||||
|
template,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
detailUrl,
|
||||||
|
fetchTemplates,
|
||||||
|
rowIndex,
|
||||||
|
}) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
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 {
|
||||||
|
summary_fields: summaryFields,
|
||||||
|
ask_inventory_on_launch: askInventoryOnLaunch,
|
||||||
|
} = template;
|
||||||
|
|
||||||
|
const missingResourceIcon =
|
||||||
|
template.type === 'job_template' &&
|
||||||
|
(!summaryFields.project ||
|
||||||
|
(!summaryFields.inventory && !askInventoryOnLaunch));
|
||||||
|
|
||||||
|
const inventoryValue = (kind, id) => {
|
||||||
|
const inventorykind = kind === 'smart' ? 'smart_inventory' : 'inventory';
|
||||||
|
|
||||||
|
return askInventoryOnLaunch ? (
|
||||||
|
<>
|
||||||
|
<Link to={`/inventories/${inventorykind}/${id}/details`}>
|
||||||
|
{summaryFields.inventory.name}
|
||||||
|
</Link>
|
||||||
|
<span> {i18n._(t`(Prompt on launch)`)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link to={`/inventories/${inventorykind}/${id}/details`}>
|
||||||
|
{summaryFields.inventory.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let lastRun = '';
|
||||||
|
const mostRecentJob = template.summary_fields.recent_jobs
|
||||||
|
? template.summary_fields.recent_jobs[0]
|
||||||
|
: null;
|
||||||
|
if (mostRecentJob) {
|
||||||
|
lastRun = mostRecentJob.finished
|
||||||
|
? formatDateString(mostRecentJob.finished)
|
||||||
|
: i18n._(t`Running`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tr id={`template-row-${template.id}`}>
|
||||||
|
<Td
|
||||||
|
expand={{
|
||||||
|
rowIndex,
|
||||||
|
isExpanded,
|
||||||
|
onToggle: () => setIsExpanded(!isExpanded),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Td
|
||||||
|
select={{
|
||||||
|
rowIndex,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}}
|
||||||
|
dataLabel={i18n._(t`Selected`)}
|
||||||
|
/>
|
||||||
|
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||||
|
<Link to={`${detailUrl}`}>
|
||||||
|
<b>{template.name}</b>
|
||||||
|
</Link>
|
||||||
|
{missingResourceIcon && (
|
||||||
|
<span>
|
||||||
|
<Tooltip
|
||||||
|
content={i18n._(t`Resources are missing from this template.`)}
|
||||||
|
position="right"
|
||||||
|
>
|
||||||
|
<ExclamationTriangleIcon css="color: #c9190b; margin-left: 20px;" />
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
<Td dataLabel={i18n._(t`Type`)}>{toTitleCase(template.type)}</Td>
|
||||||
|
<Td dataLabel={i18n._(t`Last Ran`)}>{lastRun}</Td>
|
||||||
|
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||||
|
<ActionItem
|
||||||
|
visible={template.type === 'workflow_job_template'}
|
||||||
|
tooltip={i18n._(t`Visualizer`)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
id={`template-action-visualizer-${template.id}`}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
aria-label={i18n._(t`Visualizer`)}
|
||||||
|
variant="plain"
|
||||||
|
component={Link}
|
||||||
|
to={`/templates/workflow_job_template/${template.id}/visualizer`}
|
||||||
|
>
|
||||||
|
<ProjectDiagramIcon />
|
||||||
|
</Button>
|
||||||
|
</ActionItem>
|
||||||
|
<ActionItem
|
||||||
|
visible={template.summary_fields.user_capabilities.start}
|
||||||
|
tooltip={i18n._(t`Launch Template`)}
|
||||||
|
>
|
||||||
|
<LaunchButton resource={template}>
|
||||||
|
{({ handleLaunch }) => (
|
||||||
|
<Button
|
||||||
|
id={`template-action-launch-${template.id}`}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
aria-label={i18n._(t`Launch template`)}
|
||||||
|
variant="plain"
|
||||||
|
onClick={handleLaunch}
|
||||||
|
>
|
||||||
|
<RocketIcon />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</LaunchButton>
|
||||||
|
</ActionItem>
|
||||||
|
<ActionItem
|
||||||
|
visible={template.summary_fields.user_capabilities.edit}
|
||||||
|
tooltip={i18n._(t`Edit Template`)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
id={`template-action-edit-${template.id}`}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
aria-label={i18n._(t`Edit Template`)}
|
||||||
|
variant="plain"
|
||||||
|
component={Link}
|
||||||
|
to={`/templates/${template.type}/${template.id}/edit`}
|
||||||
|
>
|
||||||
|
<PencilAltIcon />
|
||||||
|
</Button>
|
||||||
|
</ActionItem>
|
||||||
|
<ActionItem visible={template.summary_fields.user_capabilities.copy}>
|
||||||
|
<CopyButton
|
||||||
|
id={`template-action-copy-${template.id}`}
|
||||||
|
helperText={{
|
||||||
|
errorMessage: i18n._(t`Failed to copy template.`),
|
||||||
|
tooltip: i18n._(t`Copy Template`),
|
||||||
|
}}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
onCopyStart={handleCopyStart}
|
||||||
|
onCopyFinish={handleCopyFinish}
|
||||||
|
copyItem={copyTemplate}
|
||||||
|
/>
|
||||||
|
</ActionItem>
|
||||||
|
</ActionsTd>
|
||||||
|
</Tr>
|
||||||
|
<Tr isExpanded={isExpanded}>
|
||||||
|
<Td colSpan={2} />
|
||||||
|
<Td colSpan={4}>
|
||||||
|
<ExpandableRowContent>
|
||||||
|
<DetailList>
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Activity`)}
|
||||||
|
value={<Sparkline jobs={summaryFields.recent_jobs} />}
|
||||||
|
dataCy={`template-${template.id}-activity`}
|
||||||
|
/>
|
||||||
|
{summaryFields.credentials && summaryFields.credentials.length && (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Credentials`)}
|
||||||
|
value={
|
||||||
|
<ChipGroup
|
||||||
|
numChips={5}
|
||||||
|
totalChips={summaryFields.credentials.length}
|
||||||
|
>
|
||||||
|
{summaryFields.credentials.map(c => (
|
||||||
|
<CredentialChip key={c.id} credential={c} isReadOnly />
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
|
dataCy={`template-${template.id}-credentials`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{summaryFields.inventory ? (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Inventory`)}
|
||||||
|
value={inventoryValue(
|
||||||
|
summaryFields.inventory.kind,
|
||||||
|
summaryFields.inventory.id
|
||||||
|
)}
|
||||||
|
dataCy={`template-${template.id}-inventory`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
!askInventoryOnLaunch && (
|
||||||
|
<DeletedDetail label={i18n._(t`Inventory`)} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{summaryFields.labels && summaryFields.labels.results.length > 0 && (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Labels`)}
|
||||||
|
value={
|
||||||
|
<ChipGroup
|
||||||
|
numChips={5}
|
||||||
|
totalChips={summaryFields.labels.results.length}
|
||||||
|
>
|
||||||
|
{summaryFields.labels.results.map(l => (
|
||||||
|
<Chip key={l.id} isReadOnly>
|
||||||
|
{l.name}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
|
dataCy={`template-${template.id}-labels`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{summaryFields.project && (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Project`)}
|
||||||
|
value={
|
||||||
|
<Link to={`/projects/${summaryFields.project.id}/details`}>
|
||||||
|
{summaryFields.project.name}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
dataCy={`template-${template.id}-project`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Last Modified`)}
|
||||||
|
value={formatDateString(template.modified)}
|
||||||
|
dataCy={`template-${template.id}-last-modified`}
|
||||||
|
/>
|
||||||
|
</DetailList>
|
||||||
|
</ExpandableRowContent>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TemplateListItem as _TemplateListItem };
|
||||||
|
export default withI18n()(TemplateListItem);
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
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 TemplateListItem from './TemplateListItem';
|
||||||
|
|
||||||
|
jest.mock('../../api');
|
||||||
|
|
||||||
|
describe('<TemplateListItem />', () => {
|
||||||
|
test('launch button shown to users with start capabilities', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
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>
|
||||||
|
<TemplateListItem
|
||||||
|
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>
|
||||||
|
<TemplateListItem
|
||||||
|
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>
|
||||||
|
<TemplateListItem
|
||||||
|
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>
|
||||||
|
<TemplateListItem
|
||||||
|
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>
|
||||||
|
<TemplateListItem
|
||||||
|
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>
|
||||||
|
<TemplateListItem
|
||||||
|
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>
|
||||||
|
<TemplateListItem
|
||||||
|
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 templates list navigates properly', () => {
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/templates'],
|
||||||
|
});
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
detailUrl="/templates/job_template/1/details"
|
||||||
|
template={{
|
||||||
|
id: 1,
|
||||||
|
name: 'Template 1',
|
||||||
|
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/1/details'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('should call api to copy template', async () => {
|
||||||
|
JobTemplatesAPI.copy.mockResolvedValue();
|
||||||
|
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
detailUrl="/templates/job_template/1/details"
|
||||||
|
template={mockJobTemplateData}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
detailUrl="/templates/job_template/1/details"
|
||||||
|
template={mockJobTemplateData}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
detailUrl="/templates/job_template/1/details"
|
||||||
|
template={{
|
||||||
|
...mockJobTemplateData,
|
||||||
|
summary_fields: { user_capabilities: { copy: false } },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('CopyButton').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render visualizer button for workflow', async () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
detailUrl="/templates/job_template/1/details"
|
||||||
|
template={{
|
||||||
|
...mockJobTemplateData,
|
||||||
|
type: 'workflow_job_template',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('ProjectDiagramIcon').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not render visualizer button for job template', async () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
detailUrl="/templates/job_template/1/details"
|
||||||
|
template={mockJobTemplateData}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('ProjectDiagramIcon').length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
181
awx/ui_next/src/components/TemplateList/data.job_template.json
Normal file
181
awx/ui_next/src/components/TemplateList/data.job_template.json
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "job_template",
|
||||||
|
"url": "/api/v2/job_templates/7/",
|
||||||
|
"related": {
|
||||||
|
"named_url": "/api/v2/job_templates/Mike's JT/",
|
||||||
|
"created_by": "/api/v2/users/1/",
|
||||||
|
"modified_by": "/api/v2/users/1/",
|
||||||
|
"labels": "/api/v2/job_templates/7/labels/",
|
||||||
|
"inventory": "/api/v2/inventories/1/",
|
||||||
|
"project": "/api/v2/projects/6/",
|
||||||
|
"credentials": "/api/v2/job_templates/7/credentials/",
|
||||||
|
"last_job": "/api/v2/jobs/12/",
|
||||||
|
"jobs": "/api/v2/job_templates/7/jobs/",
|
||||||
|
"schedules": "/api/v2/job_templates/7/schedules/",
|
||||||
|
"activity_stream": "/api/v2/job_templates/7/activity_stream/",
|
||||||
|
"launch": "/api/v2/job_templates/7/launch/",
|
||||||
|
"notification_templates_started": "/api/v2/job_templates/7/notification_templates_started/",
|
||||||
|
"notification_templates_success": "/api/v2/job_templates/7/notification_templates_success/",
|
||||||
|
"notification_templates_error": "/api/v2/job_templates/7/notification_templates_error/",
|
||||||
|
"access_list": "/api/v2/job_templates/7/access_list/",
|
||||||
|
"survey_spec": "/api/v2/job_templates/7/survey_spec/",
|
||||||
|
"object_roles": "/api/v2/job_templates/7/object_roles/",
|
||||||
|
"instance_groups": "/api/v2/job_templates/7/instance_groups/",
|
||||||
|
"slice_workflow_jobs": "/api/v2/job_templates/7/slice_workflow_jobs/",
|
||||||
|
"copy": "/api/v2/job_templates/7/copy/",
|
||||||
|
"webhook_receiver": "/api/v2/job_templates/7/github/",
|
||||||
|
"webhook_key": "/api/v2/job_templates/7/webhook_key/"
|
||||||
|
},
|
||||||
|
"summary_fields": {
|
||||||
|
"inventory": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Mike's Inventory",
|
||||||
|
"description": "",
|
||||||
|
"has_active_failures": false,
|
||||||
|
"total_hosts": 1,
|
||||||
|
"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,
|
||||||
|
"organization_id": 1,
|
||||||
|
"kind": ""
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"id": 6,
|
||||||
|
"name": "Mike's Project",
|
||||||
|
"description": "",
|
||||||
|
"status": "successful",
|
||||||
|
"scm_type": "git"
|
||||||
|
},
|
||||||
|
"last_job": {
|
||||||
|
"id": 12,
|
||||||
|
"name": "Mike's JT",
|
||||||
|
"description": "",
|
||||||
|
"finished": "2019-10-01T14:34:35.142483Z",
|
||||||
|
"status": "successful",
|
||||||
|
"failed": false
|
||||||
|
},
|
||||||
|
"last_update": {
|
||||||
|
"id": 12,
|
||||||
|
"name": "Mike's JT",
|
||||||
|
"description": "",
|
||||||
|
"status": "successful",
|
||||||
|
"failed": false
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
},
|
||||||
|
"modified_by": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
},
|
||||||
|
"object_roles": {
|
||||||
|
"admin_role": {
|
||||||
|
"description": "Can manage all aspects of the job template",
|
||||||
|
"name": "Admin",
|
||||||
|
"id": 24
|
||||||
|
},
|
||||||
|
"execute_role": {
|
||||||
|
"description": "May run the job template",
|
||||||
|
"name": "Execute",
|
||||||
|
"id": 25
|
||||||
|
},
|
||||||
|
"read_role": {
|
||||||
|
"description": "May view settings for the job template",
|
||||||
|
"name": "Read",
|
||||||
|
"id": 26
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"user_capabilities": {
|
||||||
|
"edit": true,
|
||||||
|
"delete": true,
|
||||||
|
"start": true,
|
||||||
|
"schedule": true,
|
||||||
|
"copy": true
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"count": 1,
|
||||||
|
"results": [{
|
||||||
|
"id": 91,
|
||||||
|
"name": "L_91o2"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"survey": {
|
||||||
|
"title": "",
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"recent_jobs": [{
|
||||||
|
"id": 12,
|
||||||
|
"status": "successful",
|
||||||
|
"finished": "2019-10-01T14:34:35.142483Z",
|
||||||
|
"type": "job"
|
||||||
|
}],
|
||||||
|
"credentials": [{
|
||||||
|
"id": 1,
|
||||||
|
"kind": "ssh",
|
||||||
|
"name": "Credential 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"kind": "awx",
|
||||||
|
"name": "Credential 2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"webhook_credential": {
|
||||||
|
"id": "1",
|
||||||
|
"name": "Webhook Credential"
|
||||||
|
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created": "2019-09-30T16:18:34.564820Z",
|
||||||
|
"modified": "2019-10-01T14:47:31.818431Z",
|
||||||
|
"name": "Mike's JT",
|
||||||
|
"description": "",
|
||||||
|
"job_type": "run",
|
||||||
|
"inventory": 1,
|
||||||
|
"project": 6,
|
||||||
|
"playbook": "ping.yml",
|
||||||
|
"scm_branch": "Foo branch",
|
||||||
|
"forks": 0,
|
||||||
|
"limit": "",
|
||||||
|
"verbosity": 0,
|
||||||
|
"extra_vars": "",
|
||||||
|
"job_tags": "T_100,T_200",
|
||||||
|
"force_handlers": false,
|
||||||
|
"skip_tags": "S_100,S_200",
|
||||||
|
"start_at_task": "",
|
||||||
|
"timeout": 0,
|
||||||
|
"use_fact_cache": true,
|
||||||
|
"last_job_run": "2019-10-01T14:34:35.142483Z",
|
||||||
|
"last_job_failed": false,
|
||||||
|
"next_job_run": null,
|
||||||
|
"status": "successful",
|
||||||
|
"host_config_key": "",
|
||||||
|
"ask_scm_branch_on_launch": false,
|
||||||
|
"ask_diff_mode_on_launch": false,
|
||||||
|
"ask_variables_on_launch": false,
|
||||||
|
"ask_limit_on_launch": false,
|
||||||
|
"ask_tags_on_launch": false,
|
||||||
|
"ask_skip_tags_on_launch": false,
|
||||||
|
"ask_job_type_on_launch": false,
|
||||||
|
"ask_verbosity_on_launch": false,
|
||||||
|
"ask_inventory_on_launch": false,
|
||||||
|
"ask_credential_on_launch": false,
|
||||||
|
"survey_enabled": true,
|
||||||
|
"become_enabled": false,
|
||||||
|
"diff_mode": false,
|
||||||
|
"allow_simultaneous": false,
|
||||||
|
"custom_virtualenv": null,
|
||||||
|
"job_slice_count": 1,
|
||||||
|
"webhook_credential": 1,
|
||||||
|
"webhook_key": "asertdyuhjkhgfd234567kjgfds",
|
||||||
|
"webhook_service": "github"
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as TemplateList } from './TemplateList';
|
export { default } from './TemplateList';
|
||||||
export { default as TemplateListItem } from './TemplateListItem';
|
export { default as TemplateListItem } from './TemplateListItem';
|
||||||
@@ -23,7 +23,7 @@ import JobList from '../../components/JobList';
|
|||||||
import ContentLoading from '../../components/ContentLoading';
|
import ContentLoading from '../../components/ContentLoading';
|
||||||
import LineChart from './shared/LineChart';
|
import LineChart from './shared/LineChart';
|
||||||
import Count from './shared/Count';
|
import Count from './shared/Count';
|
||||||
import DashboardTemplateList from './shared/DashboardTemplateList';
|
import TemplateList from '../../components/TemplateList';
|
||||||
|
|
||||||
const Counts = styled.div`
|
const Counts = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -247,7 +247,9 @@ function Dashboard({ i18n }) {
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
{activeTabId === 1 && <JobList defaultParams={{ page_size: 5 }} />}
|
{activeTabId === 1 && <JobList defaultParams={{ page_size: 5 }} />}
|
||||||
{activeTabId === 2 && <DashboardTemplateList />}
|
{activeTabId === 2 && (
|
||||||
|
<TemplateList defaultParams={{ page_size: 5 }} />
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</MainPageSection>
|
</MainPageSection>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ describe('<Dashboard />', () => {
|
|||||||
.simulate('click');
|
.simulate('click');
|
||||||
});
|
});
|
||||||
pageWrapper.update();
|
pageWrapper.update();
|
||||||
expect(pageWrapper.find('DashboardTemplateList').length).toBe(1);
|
expect(pageWrapper.find('TemplateList').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders month-based/all job type chart by default', () => {
|
test('renders month-based/all job type chart by default', () => {
|
||||||
|
|||||||
@@ -1,286 +0,0 @@
|
|||||||
import React, { Fragment, useEffect, useState, useCallback } from 'react';
|
|
||||||
import { useLocation, Link } from 'react-router-dom';
|
|
||||||
import { withI18n } from '@lingui/react';
|
|
||||||
import { t } from '@lingui/macro';
|
|
||||||
import { Card, DropdownItem } 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(() => {
|
|
||||||
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');
|
|
||||||
|
|
||||||
const addTemplate = i18n._(t`Add job template`);
|
|
||||||
const addWFTemplate = i18n._(t`Add workflow template`);
|
|
||||||
const addButton = (
|
|
||||||
<AddDropDownButton
|
|
||||||
key="add"
|
|
||||||
dropdownItems={[
|
|
||||||
<DropdownItem
|
|
||||||
key={addTemplate}
|
|
||||||
component={Link}
|
|
||||||
to="/templates/job_template/add/"
|
|
||||||
aria-label={addTemplate}
|
|
||||||
>
|
|
||||||
{addTemplate}
|
|
||||||
</DropdownItem>,
|
|
||||||
<DropdownItem
|
|
||||||
component={Link}
|
|
||||||
to="/templates/workflow_job_template/add/"
|
|
||||||
key={addWFTemplate}
|
|
||||||
aria-label={addWFTemplate}
|
|
||||||
>
|
|
||||||
{addWFTemplate}
|
|
||||||
</DropdownItem>,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
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={i18n._(t`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);
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
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={i18n._(t`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);
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
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,183 +0,0 @@
|
|||||||
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 TemplateListItem({
|
|
||||||
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={i18n._(t`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 { TemplateListItem as _TemplateListItem };
|
|
||||||
export default withI18n()(TemplateListItem);
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
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 '../shared/data.job_template.json';
|
|
||||||
import TemplateListItem from './TemplateListItem';
|
|
||||||
|
|
||||||
jest.mock('../../../api');
|
|
||||||
|
|
||||||
describe('<TemplateListItem />', () => {
|
|
||||||
test('launch button shown to users with start capabilities', () => {
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<TemplateListItem
|
|
||||||
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(
|
|
||||||
<TemplateListItem
|
|
||||||
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(
|
|
||||||
<TemplateListItem
|
|
||||||
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(
|
|
||||||
<TemplateListItem
|
|
||||||
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(
|
|
||||||
<TemplateListItem
|
|
||||||
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(
|
|
||||||
<TemplateListItem
|
|
||||||
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(
|
|
||||||
<TemplateListItem
|
|
||||||
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(
|
|
||||||
<TemplateListItem
|
|
||||||
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(
|
|
||||||
<TemplateListItem
|
|
||||||
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(
|
|
||||||
<TemplateListItem
|
|
||||||
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(
|
|
||||||
<TemplateListItem
|
|
||||||
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(
|
|
||||||
<TemplateListItem
|
|
||||||
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(
|
|
||||||
<TemplateListItem
|
|
||||||
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(
|
|
||||||
<TemplateListItem
|
|
||||||
isSelected={false}
|
|
||||||
detailUrl="/templates/job_template/1/details"
|
|
||||||
template={mockJobTemplateData}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('ProjectDiagramIcon').length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -5,7 +5,7 @@ import { Route, withRouter, Switch } from 'react-router-dom';
|
|||||||
import { PageSection } from '@patternfly/react-core';
|
import { PageSection } from '@patternfly/react-core';
|
||||||
|
|
||||||
import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
|
import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
|
||||||
import { TemplateList } from './TemplateList';
|
import TemplateList from '../../components/TemplateList';
|
||||||
import Template from './Template';
|
import Template from './Template';
|
||||||
import WorkflowJobTemplate from './WorkflowJobTemplate';
|
import WorkflowJobTemplate from './WorkflowJobTemplate';
|
||||||
import JobTemplateAdd from './JobTemplateAdd';
|
import JobTemplateAdd from './JobTemplateAdd';
|
||||||
|
|||||||
Reference in New Issue
Block a user