diff --git a/CHANGELOG.md b/CHANGELOG.md index d10327b2fb..a5c527b004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/awx/ui_next/src/components/Schedule/Schedule.jsx b/awx/ui_next/src/components/Schedule/Schedule.jsx index e1e59c5d85..a725215029 100644 --- a/awx/ui_next/src/components/Schedule/Schedule.jsx +++ b/awx/ui_next/src/components/Schedule/Schedule.jsx @@ -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 && } @@ -107,6 +115,7 @@ function Schedule({ {schedule && [ - + , ]} diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx index c9e4f17fa4..2c00d989b1 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx @@ -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 ; } + 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 ( + {hasDaysToKeepField ? ( + + ) : null} 0) { await Promise.all([ ...removed.map(({ id }) => @@ -111,6 +122,7 @@ function ScheduleEdit({ history.push(`${pathRoot}schedules/${schedule.id}/details`) } diff --git a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx index 3088996a3d..cbe1526331 100644 --- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx @@ -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} /> + {hasDaysToKeepField ? ( + + ) : null} {frequency.value !== 'none' && ( @@ -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} diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx index 87c2b5dc79..5a97c3b876 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx @@ -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 diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/ManagementJobDetails.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/ManagementJobDetails.jsx deleted file mode 100644 index ebc6393b18..0000000000 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/ManagementJobDetails.jsx +++ /dev/null @@ -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); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/index.js b/awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/index.js deleted file mode 100644 index bcd1ccefec..0000000000 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ManagementJobDetails'; diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/ManagementJobEdit.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/ManagementJobEdit.jsx deleted file mode 100644 index 17908d9894..0000000000 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/ManagementJobEdit.jsx +++ /dev/null @@ -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); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/index.js b/awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/index.js deleted file mode 100644 index 2924ff2c14..0000000000 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ManagementJobEdit'; diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.jsx index ad010be710..f1a0579814 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.jsx @@ -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> diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx index 1a54713c60..439f42b0dc 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx @@ -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} /> )} diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.test.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.test.jsx new file mode 100644 index 0000000000..1152347d77 --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.test.jsx @@ -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); + }); +}); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx index 79c0e25ea1..2db3657ad3 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx @@ -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> )} </> ); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.test.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.test.jsx new file mode 100644 index 0000000000..87ae246f5d --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.test.jsx @@ -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(); + }); +}); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx index 8f9ff47304..5d26de2a4e 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx @@ -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]