mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 01:57:35 -03:30
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:
parent
a00c8920ce
commit
83b449fd30
@ -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
|
||||
|
||||
@ -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="*">
|
||||
|
||||
@ -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`)}
|
||||
|
||||
@ -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`)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
@ -1 +0,0 @@
|
||||
export { default } from './ManagementJobDetails';
|
||||
@ -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);
|
||||
@ -1 +0,0 @@
|
||||
export { default } from './ManagementJobEdit';
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user