Add sysjob data retention to schedules

* Migate management jobs list to tables
 * Use cancel link variant for consistency with other prompts
 * Add basic test coverage for sysjobs
 * Remove select-all from mgmt jobs
 * Remove unneeded component variables
 * Fix missing schedule breadcrumb
 * Optimize data fetching with useCallback
This commit is contained in:
Jake McDermott 2021-02-17 10:28:01 -05:00
parent a00c8920ce
commit 83b449fd30
No known key found for this signature in database
GPG Key ID: 0E56ED990CDFCB4F
16 changed files with 354 additions and 332 deletions

View File

@ -15,6 +15,7 @@ This is a list of high-level changes for each release of AWX. A full list of com
- Playbook, credential type, and inventory file inputs now support type-ahead and manual type-in! https://github.com/ansible/awx/pull/9120
- Added ability to relaunch against failed hosts: https://github.com/ansible/awx/pull/9225
- Added pending workflow approval count to the application header https://github.com/ansible/awx/pull/9334
- Added user interface for management jobs: https://github.com/ansible/awx/pull/9224
# 17.0.1 (January 26, 2021)
- Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152

View File

@ -95,6 +95,14 @@ function Schedule({
if (!pathname.includes('schedules/') || pathname.endsWith('edit')) {
showCardHeader = false;
}
// For some management jobs that delete data, we want to provide an additional
// field on the scheduler for configuring the number of days to retain.
const hasDaysToKeepField = [
'cleanup_activitystream',
'cleanup_jobs',
].includes(schedule?.summary_fields?.unified_job_template?.job_type);
return (
<>
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />}
@ -107,6 +115,7 @@ function Schedule({
{schedule && [
<Route key="edit" path={`${pathRoot}schedules/:id/edit`}>
<ScheduleEdit
hasDaysToKeepField={hasDaysToKeepField}
schedule={schedule}
resource={resource}
launchConfig={launchConfig}
@ -117,7 +126,11 @@ function Schedule({
key="details"
path={`${pathRoot}schedules/:scheduleId/details`}
>
<ScheduleDetail schedule={schedule} surveyConfig={surveyConfig} />
<ScheduleDetail
hasDaysToKeepField={hasDaysToKeepField}
schedule={schedule}
surveyConfig={surveyConfig}
/>
</Route>,
]}
<Route key="not-found" path="*">

View File

@ -26,6 +26,7 @@ import DeleteButton from '../../DeleteButton';
import ErrorDetail from '../../ErrorDetail';
import ChipGroup from '../../ChipGroup';
import { VariablesDetail } from '../../CodeMirrorInput';
import { parseVariableField } from '../../../util/yaml';
const PromptDivider = styled(Divider)`
margin-top: var(--pf-global--spacer--lg);
@ -42,7 +43,7 @@ const PromptDetailList = styled(DetailList)`
padding: 0px 20px;
`;
function ScheduleDetail({ schedule, i18n, surveyConfig }) {
function ScheduleDetail({ hasDaysToKeepField, schedule, i18n, surveyConfig }) {
const {
id,
created,
@ -233,6 +234,16 @@ function ScheduleDetail({ schedule, i18n, surveyConfig }) {
return <ContentError error={readContentError} />;
}
let daysToKeep = null;
if (hasDaysToKeepField && extra_data) {
if (typeof extra_data === 'string' && extra_data !== '') {
daysToKeep = parseVariableField(extra_data).days;
}
if (typeof extra_data === 'object') {
daysToKeep = extra_data?.days;
}
}
return (
<CardBody>
<ScheduleToggle
@ -254,6 +265,9 @@ function ScheduleDetail({ schedule, i18n, surveyConfig }) {
<Detail label={i18n._(t`Last Run`)} value={formatDateString(dtend)} />
<Detail label={i18n._(t`Local Time Zone`)} value={timezone} />
<Detail label={i18n._(t`Repeat Frequency`)} value={repeatFrequency} />
{hasDaysToKeepField ? (
<Detail label={i18n._(t`Days of Data to Keep`)} value={daysToKeep} />
) : null}
<ScheduleOccurrences preview={preview} />
<UserDateDetail
label={i18n._(t`Created`)}

View File

@ -17,6 +17,7 @@ import getSurveyValues from '../../../util/prompt/getSurveyValues';
function ScheduleEdit({
i18n,
hasDaysToKeepField,
schedule,
resource,
launchConfig,
@ -83,12 +84,22 @@ function ScheduleEdit({
try {
const rule = new RRule(buildRuleObj(values, i18n));
const {
data: { id: scheduleId },
} = await SchedulesAPI.update(schedule.id, {
const requestData = {
...submitValues,
rrule: rule.toString().replace(/\n/g, ' '),
});
};
if (Object.keys(values).includes('daysToKeep')) {
if (!requestData.extra_data) {
requestData.extra_data = JSON.stringify({ days: values.daysToKeep });
} else {
requestData.extra_data.days = values.daysToKeep;
}
}
const {
data: { id: scheduleId },
} = await SchedulesAPI.update(schedule.id, requestData);
if (values.credentials?.length > 0) {
await Promise.all([
...removed.map(({ id }) =>
@ -111,6 +122,7 @@ function ScheduleEdit({
<CardBody>
<ScheduleForm
schedule={schedule}
hasDaysToKeepField={hasDaysToKeepField}
handleCancel={() =>
history.push(`${pathRoot}schedules/${schedule.id}/details`)
}

View File

@ -25,6 +25,7 @@ import {
import { dateToInputDateTime, formatDateStringUTC } from '../../../util/dates';
import useRequest from '../../../util/useRequest';
import { required } from '../../../util/validators';
import { parseVariableField } from '../../../util/yaml';
import FrequencyDetailSubform from './FrequencyDetailSubform';
import SchedulePromptableFields from './SchedulePromptableFields';
@ -77,7 +78,7 @@ const generateRunOnTheDay = (days = []) => {
return null;
};
function ScheduleFormFields({ i18n, zoneOptions }) {
function ScheduleFormFields({ i18n, hasDaysToKeepField, zoneOptions }) {
const [startDateTime, startDateTimeMeta] = useField({
name: 'startDateTime',
validate: required(
@ -169,6 +170,16 @@ function ScheduleFormFields({ i18n, zoneOptions }) {
{...frequency}
/>
</FormGroup>
{hasDaysToKeepField ? (
<FormField
id="schedule-days-to-keep"
label={i18n._(t`Days of Data to Keep`)}
name="daysToKeep"
type="number"
validate={required(null, i18n)}
isRequired
/>
) : null}
{frequency.value !== 'none' && (
<SubFormLayout>
<Title size="md" headingLevel="h4">
@ -184,6 +195,7 @@ function ScheduleFormFields({ i18n, zoneOptions }) {
}
function ScheduleForm({
hasDaysToKeepField,
handleCancel,
handleSubmit,
i18n,
@ -344,6 +356,22 @@ function ScheduleForm({
);
};
if (hasDaysToKeepField) {
let initialDaysToKeep = 30;
if (schedule?.extra_data) {
if (
typeof schedule?.extra_data === 'string' &&
schedule?.extra_data !== ''
) {
initialDaysToKeep = parseVariableField(schedule?.extra_data).days;
}
if (typeof schedule?.extra_data === 'object') {
initialDaysToKeep = schedule?.extra_data?.days;
}
}
initialValues.daysToKeep = initialDaysToKeep;
}
const overriddenValues = {};
if (Object.keys(schedule).length > 0) {
@ -487,6 +515,7 @@ function ScheduleForm({
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<ScheduleFormFields
hasDaysToKeepField={hasDaysToKeepField}
i18n={i18n}
zoneOptions={zoneOptions}
{...rest}

View File

@ -22,9 +22,6 @@ import { Schedules } from '../../components/Schedule';
import { useConfig } from '../../contexts/Config';
import useRequest from '../../util/useRequest';
import ManagementJobDetails from './ManagementJobDetails';
import ManagementJobEdit from './ManagementJobEdit';
function ManagementJob({ i18n, setBreadcrumb }) {
const basePath = '/management_jobs';
@ -49,12 +46,18 @@ function ManagementJob({ i18n, setBreadcrumb }) {
setBreadcrumb(result);
}, [result, setBreadcrumb]);
const createSchedule = data =>
SystemJobTemplatesAPI.createSchedule(result.id, data);
const loadSchedules = params =>
SystemJobTemplatesAPI.readSchedules(result.id, params);
const loadScheduleOptions = () =>
SystemJobTemplatesAPI.readScheduleOptions(result.id);
const createSchedule = useCallback(
data => SystemJobTemplatesAPI.createSchedule(result.id, data),
[result]
);
const loadSchedules = useCallback(
params => SystemJobTemplatesAPI.readSchedules(result.id, params),
[result]
);
const loadScheduleOptions = useCallback(
() => SystemJobTemplatesAPI.readScheduleOptions(result.id),
[result]
);
const tabsArray = [
{
@ -69,11 +72,6 @@ function ManagementJob({ i18n, setBreadcrumb }) {
},
{
id: 0,
link: `${basePath}/${id}/details`,
name: i18n._(t`Details`),
},
{
id: 1,
name: i18n._(t`Schedules`),
link: `${match.url}/schedules`,
},
@ -81,7 +79,7 @@ function ManagementJob({ i18n, setBreadcrumb }) {
if (canReadNotifications) {
tabsArray.push({
id: 2,
id: 1,
name: i18n._(t`Notifications`),
link: `${match.url}/notifications`,
});
@ -92,37 +90,33 @@ function ManagementJob({ i18n, setBreadcrumb }) {
Tabs = null;
}
const LoadingScreen = (
<PageSection>
<Card>
{Tabs}
<ContentLoading />
</Card>
</PageSection>
);
const ErrorScreen = (
<PageSection>
<Card>
<ContentError error={error}>
{error?.response?.status === 404 && (
<span>
{i18n._(t`Management job not found.`)}
{''}
<Link to={basePath}>{i18n._(t`View all management jobs`)}</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
if (error) {
return ErrorScreen;
return (
<PageSection>
<Card>
<ContentError error={error}>
{error?.response?.status === 404 && (
<span>
{i18n._(t`Management job not found.`)}
{''}
<Link to={basePath}>{i18n._(t`View all management jobs`)}</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
if (isLoading) {
return LoadingScreen;
return (
<PageSection>
<Card>
{Tabs}
<ContentLoading />
</Card>
</PageSection>
);
}
return (
@ -133,14 +127,8 @@ function ManagementJob({ i18n, setBreadcrumb }) {
<Redirect
exact
from={`${basePath}/:id`}
to={`${basePath}/:id/details`}
to={`${basePath}/:id/schedules`}
/>
<Route path={`${basePath}/:id/details`}>
<ManagementJobDetails managementJob={result} />
</Route>
<Route path={`${basePath}/:id/edit`}>
<ManagementJobEdit managementJob={result} />
</Route>
{canReadNotifications ? (
<Route path={`${basePath}/:id/notifications`}>
<NotificationList

View File

@ -1,107 +0,0 @@
import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Link, useHistory } from 'react-router-dom';
import { Button } from '@patternfly/react-core';
import { SystemJobTemplatesAPI } from '../../../api';
import AlertModal from '../../../components/AlertModal';
import { CardBody, CardActionsRow } from '../../../components/Card';
import {
Detail,
DetailList,
UserDateDetail,
} from '../../../components/DetailList';
import ErrorDetail from '../../../components/ErrorDetail';
import { useConfig } from '../../../contexts/Config';
function ManagementJobDetails({ i18n, managementJob }) {
const { me } = useConfig();
const history = useHistory();
const [isLaunchLoading, setIsLaunchLoading] = useState(false);
const [launchError, setLaunchError] = useState(null);
const handleLaunch = async () => {
setIsLaunchLoading(true);
try {
const { data } = await SystemJobTemplatesAPI.launch(managementJob?.id);
history.push(`/jobs/management/${data.id}/output`);
} catch (error) {
setLaunchError(error);
} finally {
setIsLaunchLoading(false);
}
};
if (!managementJob) return null;
return (
<>
<CardBody>
<DetailList>
<Detail
label={i18n._(t`Name`)}
dataCy="management-job-detail-name"
value={managementJob.name}
/>
<Detail
label={i18n._(t`Description`)}
dataCy="management-job-detail-description"
value={managementJob.description}
/>
{managementJob?.has_configurable_retention ? (
<Detail
label={i18n._(t`Data retention`)}
dataCy="management-job-detail-data-retention"
value={`${managementJob.default_days} ${i18n._(t`days`)}`}
/>
) : null}
<UserDateDetail
label={i18n._(t`Created`)}
date={managementJob?.created}
user={managementJob?.summary_fields?.created_by}
/>
<UserDateDetail
label={i18n._(t`Last Modified`)}
date={managementJob.modified}
user={managementJob?.summary_fields?.modified_by}
/>
</DetailList>
<CardActionsRow>
{me?.is_superuser && managementJob?.has_configurable_retention ? (
<Button
aria-label={i18n._(t`edit`)}
component={Link}
to={`/management_jobs/${managementJob?.id}/edit`}
isDisabled={isLaunchLoading}
>
{i18n._(t`Edit`)}
</Button>
) : null}
{me?.is_superuser ? (
<Button
aria-label={i18n._(t`Launch management job`)}
variant="secondary"
onClick={handleLaunch}
isDisabled={isLaunchLoading}
>
{i18n._(t`Launch`)}
</Button>
) : null}
</CardActionsRow>
</CardBody>
<AlertModal
isOpen={Boolean(launchError)}
variant="error"
title={i18n._(t`Error!`)}
onClose={() => setLaunchError(null)}
>
{i18n._(t`Failed to launch job.`)}
<ErrorDetail error={launchError} />
</AlertModal>
</>
);
}
export default withI18n()(ManagementJobDetails);

View File

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

View File

@ -1,71 +0,0 @@
import React, { useState } from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { Formik } from 'formik';
import { Form } from '@patternfly/react-core';
import { useHistory } from 'react-router-dom';
import { SystemJobTemplatesAPI } from '../../../api';
import FormField, { FormSubmitError } from '../../../components/FormField';
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
import { FormColumnLayout } from '../../../components/FormLayout';
import { minMaxValue } from '../../../util/validators';
import { CardBody } from '../../../components/Card';
function ManagementJobEdit({ i18n, managementJob }) {
const history = useHistory();
const [formError, setFormError] = useState(null);
const handleCancel = () => {
history.push(`/management_jobs/${managementJob?.id}/details`);
};
const handleSubmit = async values => {
try {
await SystemJobTemplatesAPI.update(managementJob?.id, {
default_days: values.dataRetention,
});
history.push(`/management_jobs/${managementJob?.id}/details`);
} catch (error) {
setFormError(error);
}
};
return (
<CardBody>
{managementJob?.default_days ? (
<Formik
initialValues={{
dataRetention: managementJob?.default_days || null,
description: i18n._(t`Delete data older than this number of days.`),
}}
onSubmit={handleSubmit}
>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<FormField
id="data-retention"
name="dataRetention"
type="number"
label={i18n._(t`Data Retention (Days)`)}
tooltip={i18n._(
t`Delete data older than this number of days.`
)}
validate={minMaxValue(0, Number.MAX_SAFE_INTEGER, i18n)}
/>
<FormSubmitError error={formError} />
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
/>
</FormColumnLayout>
</Form>
)}
</Formik>
) : null}
</CardBody>
);
}
export default withI18n()(ManagementJobEdit);

View File

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

View File

@ -6,6 +6,8 @@ import { RocketIcon } from '@patternfly/react-icons';
import AlertModal from '../../../components/AlertModal';
const MAX_RETENTION = 99999;
const clamp = (val, min, max) => {
if (val < min) {
return min;
@ -58,7 +60,7 @@ function LaunchManagementPrompt({
<Button
id="launch-job-cancel-button"
key="cancel"
variant="secondary"
variant="link"
aria-label={i18n._(t`Cancel`)}
onClick={onClose}
>
@ -70,9 +72,7 @@ function LaunchManagementPrompt({
<TextInput
value={dataRetention}
type="number"
onChange={value =>
setDataRetention(clamp(value, 0, Number.MAX_SAFE_INTEGER))
}
onChange={value => setDataRetention(clamp(value, 0, MAX_RETENTION))}
aria-label={i18n._(t`Launch`)}
/>
</AlertModal>

View File

@ -8,7 +8,10 @@ import { SystemJobTemplatesAPI } from '../../../api';
import AlertModal from '../../../components/AlertModal';
import DatalistToolbar from '../../../components/DataListToolbar';
import ErrorDetail from '../../../components/ErrorDetail';
import PaginatedDataList from '../../../components/PaginatedDataList';
import PaginatedTable, {
HeaderRow,
HeaderCell,
} from '../../../components/PaginatedTable';
import { useConfig } from '../../../contexts/Config';
import { parseQueryString, getQSConfig } from '../../../util/qs';
import useRequest from '../../../util/useRequest';
@ -70,7 +73,7 @@ function ManagementJobList({ i18n }) {
<>
<PageSection>
<Card>
<PaginatedDataList
<PaginatedTable
qsConfig={QS_CONFIG}
contentError={error}
hasContentLoading={isLoading}
@ -86,21 +89,22 @@ function ManagementJobList({ i18n }) {
qsConfig={QS_CONFIG}
/>
)}
renderItem={({
id,
name,
description,
has_configurable_retention,
default_days,
}) => (
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
<HeaderCell>{i18n._(t`Description`)}</HeaderCell>
</HeaderRow>
}
renderRow={({ id, name, description, job_type }) => (
<ManagementJobListItem
key={id}
id={id}
name={name}
description={description}
isSuperUser={me?.is_superuser}
isConfigurable={has_configurable_retention}
defaultDays={default_days}
isPrompted={['cleanup_activitystream', 'cleanup_jobs'].includes(
job_type
)}
onLaunchError={setLaunchError}
/>
)}

View File

@ -0,0 +1,111 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { SystemJobTemplatesAPI } from '../../../api';
import ManagementJobList from './ManagementJobList';
jest.mock('../../../api/models/SystemJobTemplates');
const managementJobs = {
data: {
results: [
{
id: 1,
name: 'Cleanup Activity Stream',
description: 'Remove activity stream history',
job_type: 'cleanup_activitystream',
url: '/api/v2/system_job_templates/1/',
},
{
id: 2,
name: 'Cleanup Expired OAuth 2 Tokens',
description: 'Cleanup expired OAuth 2 access and refresh tokens',
job_type: 'cleanup_tokens',
url: '/api/v2/system_job_templates/2/',
},
{
id: 3,
name: 'Cleanup Expired Sessions',
description: 'Cleans out expired browser sessions',
job_type: 'cleanup_sessions',
url: '/api/v2/system_job_templates/3/',
},
{
id: 4,
name: 'Cleanup Job Details',
description: 'Remove job history older than X days',
job_type: 'cleanup_tokens',
url: '/api/v2/system_job_templates/4/',
},
],
count: 4,
},
};
const options = { data: { actions: { POST: true } } };
describe('<ManagementJobList/>', () => {
beforeEach(() => {
SystemJobTemplatesAPI.read.mockResolvedValue(managementJobs);
SystemJobTemplatesAPI.readOptions.mockResolvedValue(options);
});
afterEach(() => {
jest.clearAllMocks();
});
let wrapper;
test('should mount successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(<ManagementJobList />);
});
await waitForElement(wrapper, 'ManagementJobList', el => el.length > 0);
});
test('should have data fetched and render 4 rows', async () => {
await act(async () => {
wrapper = mountWithContexts(<ManagementJobList />);
});
await waitForElement(wrapper, 'ManagementJobList', el => el.length > 0);
expect(wrapper.find('ManagementJobListItem').length).toBe(4);
expect(SystemJobTemplatesAPI.read).toBeCalled();
expect(SystemJobTemplatesAPI.readOptions).toBeCalled();
});
test('should throw content error', async () => {
SystemJobTemplatesAPI.read.mockRejectedValue(
new Error({
response: {
config: {
method: 'GET',
url: '/api/v2/system_job_templates',
},
data: 'An error occurred',
},
})
);
await act(async () => {
wrapper = mountWithContexts(<ManagementJobList />);
});
await waitForElement(wrapper, 'ManagementJobList', el => el.length > 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
test('should not render add button', async () => {
SystemJobTemplatesAPI.read.mockResolvedValue(managementJobs);
SystemJobTemplatesAPI.readOptions.mockResolvedValue({
data: { actions: { POST: false } },
});
await act(async () => {
wrapper = mountWithContexts(<ManagementJobList />);
});
waitForElement(wrapper, 'ManagementJobList', el => el.length > 0);
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
});
});

View File

@ -2,43 +2,26 @@ import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Link, useHistory } from 'react-router-dom';
import {
Button,
DataListAction as _DataListAction,
DataListCell,
DataListItem,
DataListItemRow,
DataListItemCells,
Tooltip,
} from '@patternfly/react-core';
import { RocketIcon, PencilAltIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { Button, Tooltip } from '@patternfly/react-core';
import { Tr, Td } from '@patternfly/react-table';
import { RocketIcon } from '@patternfly/react-icons';
import { SystemJobTemplatesAPI } from '../../../api';
import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
import LaunchManagementPrompt from './LaunchManagementPrompt';
const DataListAction = styled(_DataListAction)`
align-items: center;
display: grid;
grid-gap: 16px;
grid-template-columns: repeat(2, 40px);
`;
function ManagementJobListItem({
i18n,
onLaunchError,
isConfigurable,
isPrompted,
isSuperUser,
id,
name,
description,
defaultDays,
}) {
const detailsUrl = `/management_jobs/${id}/details`;
const editUrl = `/management_jobs/${id}/edit`;
const labelId = `mgmt-job-action-${id}`;
const detailsUrl = `/management_jobs/${id}`;
const history = useHistory();
const [isLaunchLoading, setIsLaunchLoading] = useState(false);
@ -79,30 +62,22 @@ function ManagementJobListItem({
return (
<>
<DataListItem key={id} id={id} aria-labelledby={labelId}>
<DataListItemRow>
<DataListItemCells
dataListCells={[
<DataListCell
key="name"
aria-label={i18n._(t`management job name`)}
>
<Link to={detailsUrl}>
<b>{name}</b>
</Link>
</DataListCell>,
<DataListCell
key="description"
aria-label={i18n._(t`management job description`)}
>
<strong>{i18n._(t`Description:`)}</strong> {description}
</DataListCell>,
]}
/>
<DataListAction aria-labelledby={labelId} id={labelId}>
<Tr id={`mgmt-jobs-row-${id}`}>
<Td />
<Td dataLabel={i18n._(t`Name`)}>
<Link to={`${detailsUrl}`}>
<b>{name}</b>
</Link>
</Td>
<Td dataLabel={i18n._(t`Description`)}>{description}</Td>
<ActionsTd dataLabel={i18n._(t`Actions`)}>
<ActionItem
visible={isSuperUser}
tooltip={i18n._(t`Launch Management Job`)}
>
{isSuperUser ? (
<>
{isConfigurable ? (
{isPrompted ? (
<>
<LaunchManagementPrompt
isOpen={isManagementPromptOpen}
@ -110,22 +85,8 @@ function ManagementJobListItem({
onClick={handleManagementPromptClick}
onClose={handleManagementPromptClose}
onConfirm={handleManagementPromptConfirm}
defaultDays={defaultDays}
defaultDays={30}
/>
<Tooltip
content={i18n._(t`Edit management job`)}
position="top"
>
<Button
aria-label={i18n._(t`Edit management job`)}
variant="plain"
component={Link}
to={editUrl}
isDisabled={isLaunchLoading}
>
<PencilAltIcon />
</Button>
</Tooltip>
</>
) : (
<Tooltip
@ -144,21 +105,19 @@ function ManagementJobListItem({
)}{' '}
</>
) : null}
</DataListAction>
</DataListItemRow>
</DataListItem>
</ActionItem>
</ActionsTd>
</Tr>
{managementPromptError && (
<>
<AlertModal
isOpen={managementPromptError}
variant="danger"
onClose={() => setManagementPromptError(null)}
title={i18n._(t`Management job launch error`)}
label={i18n._(t`Management job launch error`)}
>
<ErrorDetail error={managementPromptError} />
</AlertModal>
</>
<AlertModal
isOpen={managementPromptError}
variant="danger"
onClose={() => setManagementPromptError(null)}
title={i18n._(t`Management job launch error`)}
label={i18n._(t`Management job launch error`)}
>
<ErrorDetail error={managementPromptError} />
</AlertModal>
)}
</>
);

View File

@ -0,0 +1,69 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import ManagementJobListItem from './ManagementJobListItem';
describe('<ManagementJobListItem/>', () => {
let wrapper;
const managementJob = {
id: 3,
name: 'Cleanup Expired Sessions',
description: 'Cleans out expired browser sessions',
job_type: 'cleanup_sessions',
url: '/api/v2/system_job_templates/3/',
};
test('should mount successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(
<table>
<tbody>
<ManagementJobListItem
id={managementJob.id}
name={managementJob.name}
description={managementJob.description}
isSuperUser
onLaunchError={() => {}}
/>
</tbody>
</table>
);
});
expect(wrapper.find('ManagementJobListItem').length).toBe(1);
});
test('should render the proper data', async () => {
await act(async () => {
wrapper = mountWithContexts(
<table>
<tbody>
<ManagementJobListItem
id={managementJob.id}
name={managementJob.name}
description={managementJob.description}
isSuperUser
onLaunchError={() => {}}
/>
</tbody>
</table>
);
});
expect(
wrapper
.find('Td')
.at(1)
.text()
).toBe(managementJob.name);
expect(
wrapper
.find('Td')
.at(2)
.text()
).toBe(managementJob.description);
expect(wrapper.find('RocketIcon').exists()).toBeTruthy();
});
});

View File

@ -21,14 +21,16 @@ function ManagementJobs({ i18n }) {
setBreadcrumbConfig({
[basePath]: i18n._(t`Management job`),
[`${basePath}/${id}`]: name,
[`${basePath}/${id}/details`]: i18n._(t`Details`),
[`${basePath}/${id}/edit`]: i18n._(t`Edit details`),
[`${basePath}/${id}/notifications`]: i18n._(t`Notifications`),
[`${basePath}/schedules`]: i18n._(t`Schedules`),
[`${basePath}/schedules/add`]: i18n._(t`Create New Schedule`),
[`${basePath}/schedules/${nested?.id}`]: `${nested?.name}`,
[`${basePath}/schedules/${nested?.id}/details`]: i18n._(t`Details`),
[`${basePath}/schedules/${nested?.id}/edit`]: i18n._(t`Edit Details`),
[`${basePath}/${id}/schedules`]: i18n._(t`Schedules`),
[`${basePath}/${id}/schedules/add`]: i18n._(t`Create New Schedule`),
[`${basePath}/${id}/schedules/${nested?.id}`]: `${nested?.name}`,
[`${basePath}/${id}/schedules/${nested?.id}/details`]: i18n._(
t`Details`
),
[`${basePath}/${id}/schedules/${nested?.id}/edit`]: i18n._(
t`Edit Details`
),
});
},
[i18n]