From d3f2dedbd5de538227200f72ae9509c9709ae72e Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 8 Sep 2020 13:19:20 -0400 Subject: [PATCH 01/21] Add routing system for mgmt jobs --- awx/ui_next/src/api/index.js | 3 + .../src/api/models/SystemJobTemplates.js | 14 ++ .../screens/ManagementJob/ManagementJob.jsx | 147 ++++++++++++++++++ .../ManagementJobDetails.jsx | 10 ++ .../ManagementJobDetails/index.js | 1 + .../ManagementJobEdit/ManagementJobEdit.jsx | 9 ++ .../ManagementJob/ManagementJobEdit/index.js | 1 + .../ManagementJobList/ManagementJobList.jsx | 13 ++ .../ManagementJob/ManagementJobList/index.js | 1 + .../ManagementJobNotifications.jsx | 9 ++ .../ManagementJobNotifications/index.js | 1 + .../ManagementJobSchedules.jsx | 10 ++ .../ManagementJobSchedules/index.js | 1 + .../screens/ManagementJob/ManagementJobs.jsx | 48 +++++- .../ManagementJob/ManagementJobs.test.jsx | 5 +- 15 files changed, 265 insertions(+), 8 deletions(-) create mode 100644 awx/ui_next/src/api/models/SystemJobTemplates.js create mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx create mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/ManagementJobDetails.jsx create mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/index.js create mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/ManagementJobEdit.jsx create mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/index.js create mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx create mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobList/index.js create mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobNotifications/ManagementJobNotifications.jsx create mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobNotifications/index.js create mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobSchedules/ManagementJobSchedules.jsx create mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobSchedules/index.js diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index cddf01e259..3160ebd907 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -29,6 +29,7 @@ import Root from './models/Root'; import Schedules from './models/Schedules'; import Settings from './models/Settings'; import SystemJobs from './models/SystemJobs'; +import SystemJobTemplates from './models/SystemJobTemplates'; import Teams from './models/Teams'; import Tokens from './models/Tokens'; import UnifiedJobTemplates from './models/UnifiedJobTemplates'; @@ -71,6 +72,7 @@ const RootAPI = new Root(); const SchedulesAPI = new Schedules(); const SettingsAPI = new Settings(); const SystemJobsAPI = new SystemJobs(); +const SystemJobTemplatesAPI = new SystemJobTemplates(); const TeamsAPI = new Teams(); const TokensAPI = new Tokens(); const UnifiedJobTemplatesAPI = new UnifiedJobTemplates(); @@ -114,6 +116,7 @@ export { SchedulesAPI, SettingsAPI, SystemJobsAPI, + SystemJobTemplatesAPI, TeamsAPI, TokensAPI, UnifiedJobTemplatesAPI, diff --git a/awx/ui_next/src/api/models/SystemJobTemplates.js b/awx/ui_next/src/api/models/SystemJobTemplates.js new file mode 100644 index 0000000000..54712b9abe --- /dev/null +++ b/awx/ui_next/src/api/models/SystemJobTemplates.js @@ -0,0 +1,14 @@ +import Base from '../Base'; + +class SystemJobTemplates extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/system_job_templates/'; + } + + readDetail(id) { + return this.http.get(`${this.baseUrl}${id}/`).then(({ data }) => data); + } +} + +export default SystemJobTemplates; diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx new file mode 100644 index 0000000000..a875f95141 --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx @@ -0,0 +1,147 @@ +import React, { useEffect, useCallback } from 'react'; +import { + Link, + Redirect, + Route, + Switch, + useLocation, + useParams, +} from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { CaretLeftIcon } from '@patternfly/react-icons'; +import { Card, PageSection } from '@patternfly/react-core'; +import { useRouteMatch } from 'react-router-dom'; + +import { SystemJobTemplatesAPI } from '../../api'; + +import ContentError from '../../components/ContentError'; +import ContentLoading from '../../components/ContentLoading'; +import RoutedTabs from '../../components/RoutedTabs'; +import { useConfig } from '../../contexts/Config'; +import useRequest from '../../util/useRequest'; + +import ManagementJobDetails from './ManagementJobDetails'; +import ManagementJobEdit from './ManagementJobEdit'; +import ManagementJobNotifications from './ManagementJobNotifications'; +import ManagementJobSchedules from './ManagementJobSchedules'; + +function ManagementJob({ i18n, setBreadcrumb }) { + const segment = '/management_jobs'; + + const match = useRouteMatch(); + const { id } = useParams(); + const { pathname } = useLocation(); + const { me, isNotificationAdmin } = useConfig(); + + const { isLoading, error, request, result } = useRequest( + useCallback(async () => SystemJobTemplatesAPI.readDetail(id), [id]) + ); + + useEffect(() => { + request(); + }, [request, pathname]); + + useEffect(() => { + if (!result) return; + + setBreadcrumb(result); + }, [result, setBreadcrumb]); + + const tabsArray = [ + { + id: 99, + link: segment, + name: ( + <> + + {i18n._(t`Back to management jobs`)} + + ), + }, + { + id: 0, + link: `${segment}/${id}/details`, + name: i18n._(t`Details`), + }, + { + id: 1, + name: i18n._(t`Schedules`), + link: `${match.url}/schedules`, + }, + ]; + + if (me?.is_system_auditor || isNotificationAdmin) { + tabsArray.push({ + id: 2, + name: i18n._(t`Notifications`), + link: `${match.url}/notifications`, + }); + } + + const LoadingScreen = ( + + + {pathname.endsWith('edit') ? null : ( + + )} + + + + ); + + const ErrorScreen = ( + + + + {error?.response?.status === 404 && ( + + {i18n._(t`Management job not found.`)} + {''} + {i18n._(t`View all management jobs`)} + + )} + + + + ); + + if (error) { + return ErrorScreen; + } + + if (isLoading) { + return LoadingScreen; + } + + return ( + + + {pathname.endsWith('edit') ? null : ( + + )} + + + + + + + + + + + + + + + + + + ); +} + +export default withI18n()(ManagementJob); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/ManagementJobDetails.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/ManagementJobDetails.jsx new file mode 100644 index 0000000000..2ed939563a --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/ManagementJobDetails.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; + +import { CardBody } from '../../../components/Card'; + +function ManagementJobDetails() { + return Management Job Details; +} + +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 new file mode 100644 index 0000000000..bcd1ccefec --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/index.js @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000..9ddff5539a --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/ManagementJobEdit.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +import { CardBody } from '../../../components/Card'; + +function ManagementJobEdit() { + return Management Job Edit; +} + +export default ManagementJobEdit; diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/index.js b/awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/index.js new file mode 100644 index 0000000000..2924ff2c14 --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/index.js @@ -0,0 +1 @@ +export { default } from './ManagementJobEdit'; diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx new file mode 100644 index 0000000000..d1fdde1c37 --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { Card, PageSection } from '@patternfly/react-core'; + +function ManagementJobDetails() { + return ( + + Management Job List + + ); +} + +export default withI18n()(ManagementJobDetails); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/index.js b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/index.js new file mode 100644 index 0000000000..e55f0f261f --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/index.js @@ -0,0 +1 @@ +export { default } from './ManagementJobList'; diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobNotifications/ManagementJobNotifications.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobNotifications/ManagementJobNotifications.jsx new file mode 100644 index 0000000000..43d8ba78c0 --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobNotifications/ManagementJobNotifications.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +import { CardBody } from '../../../components/Card'; + +function ManagementJobNotifications({ managementJob }) { + return Management Job Notifications; +} + +export default ManagementJobNotifications; diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobNotifications/index.js b/awx/ui_next/src/screens/ManagementJob/ManagementJobNotifications/index.js new file mode 100644 index 0000000000..799d7c45fb --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobNotifications/index.js @@ -0,0 +1 @@ +export { default } from './ManagementJobNotifications'; diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobSchedules/ManagementJobSchedules.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobSchedules/ManagementJobSchedules.jsx new file mode 100644 index 0000000000..e3f48f9c57 --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobSchedules/ManagementJobSchedules.jsx @@ -0,0 +1,10 @@ +import React from 'react'; + +import { CardBody } from '../../../components/Card'; +import { Schedules } from '../../../components/Schedule'; + +function ManagementJobSchedules({ managementJob }) { + return Management Job Schedules; +} + +export default ManagementJobSchedules; diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobSchedules/index.js b/awx/ui_next/src/screens/ManagementJob/ManagementJobSchedules/index.js new file mode 100644 index 0000000000..983527b91a --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobSchedules/index.js @@ -0,0 +1 @@ +export { default } from './ManagementJobSchedules'; diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx index 94f5a077c5..8f9ff47304 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx @@ -1,17 +1,51 @@ -import React, { Fragment } from 'react'; +import React, { useState, useCallback } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { Route, Switch } from 'react-router-dom'; import ScreenHeader from '../../components/ScreenHeader'; +import ManagementJob from './ManagementJob'; +import ManagementJobList from './ManagementJobList'; function ManagementJobs({ i18n }) { + const basePath = '/management_jobs'; + + const [breadcrumbConfig, setBreadcrumbConfig] = useState({ + [basePath]: i18n._(t`Management jobs`), + }); + + const buildBreadcrumbConfig = useCallback( + ({ id, name }, nested) => { + if (!id) return; + + 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`), + }); + }, + [i18n] + ); + return ( - - - + <> + + + + + + + + + + ); } diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx index df422fe8ec..04e9ed4894 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx @@ -10,17 +10,20 @@ jest.mock('react-router-dom', () => ({ describe('', () => { let pageWrapper; + let pageSections; beforeEach(() => { pageWrapper = mountWithContexts(); + pageSections = pageWrapper.find('PageSection'); }); afterEach(() => { pageWrapper.unmount(); }); - test('initially renders without crashing', () => { + test('renders ok', () => { expect(pageWrapper.length).toBe(1); expect(pageWrapper.find('ScreenHeader').length).toBe(1); + expect(pageSections.length).toBe(1); }); }); From 5d9d486f9c4a89174c7adb91b300af476cce2e81 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 8 Sep 2020 14:46:33 -0400 Subject: [PATCH 02/21] Resolve notification admin status with config --- .../components/AppContainer/AppContainer.jsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/awx/ui_next/src/components/AppContainer/AppContainer.jsx b/awx/ui_next/src/components/AppContainer/AppContainer.jsx index 0abd198c07..bf366b2edb 100644 --- a/awx/ui_next/src/components/AppContainer/AppContainer.jsx +++ b/awx/ui_next/src/components/AppContainer/AppContainer.jsx @@ -12,7 +12,7 @@ import { t } from '@lingui/macro'; import { withI18n } from '@lingui/react'; import styled from 'styled-components'; -import { ConfigAPI, MeAPI, RootAPI } from '../../api'; +import { ConfigAPI, MeAPI, OrganizationsAPI, RootAPI } from '../../api'; import { ConfigProvider } from '../../contexts/Config'; import { SESSION_TIMEOUT_KEY } from '../../constants'; import { isAuthenticated } from '../../util/auth'; @@ -148,8 +148,22 @@ function AppContainer({ i18n, navRouteConfig = [], children }) { results: [me], }, }, - ] = await Promise.all([ConfigAPI.read(), MeAPI.read()]); - setConfig({ ...data, me }); + { + data: { results: notificationAdminResults }, + }, + ] = await Promise.all([ + ConfigAPI.read(), + MeAPI.read(), + OrganizationsAPI.read({ + page_size: 1, + role_level: 'notification_admin_role', + }), + ]); + setConfig({ + ...data, + me, + isNotificationAdmin: Boolean(notificationAdminResults?.length), + }); setIsReady(true); } catch (err) { if (err.response.status === 401) { From 3f936cd5e70aad315d8f669fd42ce7bfffc9a647 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 9 Sep 2020 11:16:09 -0400 Subject: [PATCH 03/21] Add mgmt job schedules --- .../src/api/models/SystemJobTemplates.js | 10 +++- .../screens/ManagementJob/ManagementJob.jsx | 57 ++++++++++++------- .../ManagementJobSchedules.jsx | 10 ---- .../ManagementJobSchedules/index.js | 1 - 4 files changed, 44 insertions(+), 34 deletions(-) delete mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobSchedules/ManagementJobSchedules.jsx delete mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobSchedules/index.js diff --git a/awx/ui_next/src/api/models/SystemJobTemplates.js b/awx/ui_next/src/api/models/SystemJobTemplates.js index 54712b9abe..7520a3a3d3 100644 --- a/awx/ui_next/src/api/models/SystemJobTemplates.js +++ b/awx/ui_next/src/api/models/SystemJobTemplates.js @@ -1,13 +1,19 @@ import Base from '../Base'; +import NotificationsMixin from '../mixins/Notifications.mixin'; +import SchedulesMixin from '../mixins/Schedules.mixin'; -class SystemJobTemplates extends Base { +const Mixins = SchedulesMixin(NotificationsMixin(Base)); + +class SystemJobTemplates extends Mixins { constructor(http) { super(http); this.baseUrl = '/api/v2/system_job_templates/'; } readDetail(id) { - return this.http.get(`${this.baseUrl}${id}/`).then(({ data }) => data); + const path = `${this.baseUrl}${id}/`; + + return this.http.get(path).then(({ data }) => data); } } diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx index a875f95141..be0bedb8d6 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx @@ -6,34 +6,35 @@ import { Switch, useLocation, useParams, + useRouteMatch, } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { CaretLeftIcon } from '@patternfly/react-icons'; import { Card, PageSection } from '@patternfly/react-core'; -import { useRouteMatch } from 'react-router-dom'; import { SystemJobTemplatesAPI } from '../../api'; - import ContentError from '../../components/ContentError'; import ContentLoading from '../../components/ContentLoading'; import RoutedTabs from '../../components/RoutedTabs'; +import { Schedules } from '../../components/Schedule'; import { useConfig } from '../../contexts/Config'; import useRequest from '../../util/useRequest'; import ManagementJobDetails from './ManagementJobDetails'; import ManagementJobEdit from './ManagementJobEdit'; import ManagementJobNotifications from './ManagementJobNotifications'; -import ManagementJobSchedules from './ManagementJobSchedules'; function ManagementJob({ i18n, setBreadcrumb }) { - const segment = '/management_jobs'; + const basePath = '/management_jobs'; const match = useRouteMatch(); const { id } = useParams(); const { pathname } = useLocation(); const { me, isNotificationAdmin } = useConfig(); + const canReadNotifications = isNotificationAdmin || me?.is_system_auditor; + const { isLoading, error, request, result } = useRequest( useCallback(async () => SystemJobTemplatesAPI.readDetail(id), [id]) ); @@ -48,10 +49,17 @@ 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 tabsArray = [ { id: 99, - link: segment, + link: basePath, name: ( <> @@ -61,7 +69,7 @@ function ManagementJob({ i18n, setBreadcrumb }) { }, { id: 0, - link: `${segment}/${id}/details`, + link: `${basePath}/${id}/details`, name: i18n._(t`Details`), }, { @@ -71,7 +79,7 @@ function ManagementJob({ i18n, setBreadcrumb }) { }, ]; - if (me?.is_system_auditor || isNotificationAdmin) { + if (canReadNotifications) { tabsArray.push({ id: 2, name: i18n._(t`Notifications`), @@ -79,12 +87,15 @@ function ManagementJob({ i18n, setBreadcrumb }) { }); } + let Tabs = ; + if (pathname.includes('edit') || pathname.includes('schedules/')) { + Tabs = null; + } + const LoadingScreen = ( - {pathname.endsWith('edit') ? null : ( - - )} + {Tabs} @@ -98,7 +109,7 @@ function ManagementJob({ i18n, setBreadcrumb }) { {i18n._(t`Management job not found.`)} {''} - {i18n._(t`View all management jobs`)} + {i18n._(t`View all management jobs`)} )} @@ -117,26 +128,30 @@ function ManagementJob({ i18n, setBreadcrumb }) { return ( - {pathname.endsWith('edit') ? null : ( - - )} + {Tabs} - + - + - + - - + + diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobSchedules/ManagementJobSchedules.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobSchedules/ManagementJobSchedules.jsx deleted file mode 100644 index e3f48f9c57..0000000000 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobSchedules/ManagementJobSchedules.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; - -import { CardBody } from '../../../components/Card'; -import { Schedules } from '../../../components/Schedule'; - -function ManagementJobSchedules({ managementJob }) { - return Management Job Schedules; -} - -export default ManagementJobSchedules; diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobSchedules/index.js b/awx/ui_next/src/screens/ManagementJob/ManagementJobSchedules/index.js deleted file mode 100644 index 983527b91a..0000000000 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobSchedules/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ManagementJobSchedules'; From 45acd15c82ff90a5f9e1fef748ce847b9ce9289c Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 9 Sep 2020 11:20:47 -0400 Subject: [PATCH 04/21] Add mgmt job notifications --- .../src/screens/ManagementJob/ManagementJob.jsx | 14 ++++++++++---- .../ManagementJobNotifications.jsx | 9 --------- .../ManagementJobNotifications/index.js | 1 - 3 files changed, 10 insertions(+), 14 deletions(-) delete mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobNotifications/ManagementJobNotifications.jsx delete mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobNotifications/index.js diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx index be0bedb8d6..87c2b5dc79 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx @@ -16,6 +16,7 @@ import { Card, PageSection } from '@patternfly/react-core'; import { SystemJobTemplatesAPI } from '../../api'; import ContentError from '../../components/ContentError'; import ContentLoading from '../../components/ContentLoading'; +import NotificationList from '../../components/NotificationList'; import RoutedTabs from '../../components/RoutedTabs'; import { Schedules } from '../../components/Schedule'; import { useConfig } from '../../contexts/Config'; @@ -23,7 +24,6 @@ import useRequest from '../../util/useRequest'; import ManagementJobDetails from './ManagementJobDetails'; import ManagementJobEdit from './ManagementJobEdit'; -import ManagementJobNotifications from './ManagementJobNotifications'; function ManagementJob({ i18n, setBreadcrumb }) { const basePath = '/management_jobs'; @@ -141,9 +141,15 @@ function ManagementJob({ i18n, setBreadcrumb }) { - - - + {canReadNotifications ? ( + + + + ) : null} Management Job Notifications; -} - -export default ManagementJobNotifications; diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobNotifications/index.js b/awx/ui_next/src/screens/ManagementJob/ManagementJobNotifications/index.js deleted file mode 100644 index 799d7c45fb..0000000000 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobNotifications/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ManagementJobNotifications'; From eaf55728d89d402a131e30ba0f5ff32f54e67d4c Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 9 Sep 2020 17:28:09 -0400 Subject: [PATCH 05/21] Add mgmt job list --- .../ManagementJobList/ManagementJobList.jsx | 100 ++++++++++++++++-- .../ManagementJobListItem.jsx | 74 +++++++++++++ 2 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx index d1fdde1c37..89756a43ee 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx @@ -1,13 +1,101 @@ -import React from 'react'; +import React, { useEffect, useCallback } from 'react'; +import { t } from '@lingui/macro'; import { withI18n } from '@lingui/react'; +import { useLocation } from 'react-router-dom'; import { Card, PageSection } from '@patternfly/react-core'; -function ManagementJobDetails() { +import { SystemJobTemplatesAPI } from '../../../api'; +import { useConfig } from '../../../contexts/Config'; +import { getQSConfig, parseQueryString } from '../../../util/qs'; +import useRequest from '../../../util/useRequest'; +import DatalistToolbar from '../../../components/DataListToolbar'; +import PaginatedDataList from '../../../components/PaginatedDataList'; + +import ManagementJobListItem from './ManagementJobListItem'; + +const QS_CONFIG = getQSConfig('management_jobs', { + page: 1, + page_size: 20, +}); + +const buildSearchKeys = options => { + const actions = options?.data?.actions?.GET || {}; + const searchableKeys = Object.keys(actions).filter( + key => actions[key].filterable + ); + const relatedSearchableKeys = options?.data?.related_search_fields || []; + + return { searchableKeys, relatedSearchableKeys }; +}; + +const loadManagementJobs = async search => { + const params = parseQueryString(QS_CONFIG, search); + const [ + { + data: { results: items, count }, + }, + options, + ] = await Promise.all([ + SystemJobTemplatesAPI.read(params), + SystemJobTemplatesAPI.readOptions(), + ]); + + return { items, count, options }; +}; + +function ManagementJobList({ i18n }) { + const { search } = useLocation(); + const { me } = useConfig(); + + const { + request, + error = false, + isLoading = true, + result: { options = {}, items = [], count = 0 }, + } = useRequest( + useCallback(async () => loadManagementJobs(search), [search]), + {} + ); + + useEffect(() => { + request(); + }, [request]); + + const { searchableKeys, relatedSearchableKeys } = buildSearchKeys(options); + return ( - - Management Job List - + <> + + + ( + + )} + renderItem={({ id, name, description }) => ( + + )} + /> + + + ); } -export default withI18n()(ManagementJobDetails); +export default withI18n()(ManagementJobList); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx new file mode 100644 index 0000000000..7dd31d4ef0 --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Link } from 'react-router-dom'; +import { + Button, + DataListAction as _DataListAction, + DataListItem, + DataListItemRow, + DataListItemCells, + Tooltip, +} from '@patternfly/react-core'; +import { PencilAltIcon } from '@patternfly/react-icons'; +import styled from 'styled-components'; + +import DataListCell from '../../../components/DataListCell'; + +const DataListAction = styled(_DataListAction)` + align-items: center; + display: grid; + grid-gap: 16px; + grid-template-columns: 40px; +`; + +function ManagementJobListItem({ i18n, isSuperUser, id, name, description }) { + const detailsUrl = `/management_jobs/${id}/details`; + const editUrl = `/management_jobs/${id}/edit`; + const labelId = `mgmt-job-action-${id}`; + + return ( + + + + + {name} + + , + + {i18n._(t`Description:`)} {description} + , + ]} + /> + + {isSuperUser ? ( + + + + ) : null} + + + + ); +} + +export default withI18n()(ManagementJobListItem); From daaabd935c2a16e07c790a1a2281b27cf6ebd5a6 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Thu, 10 Sep 2020 12:34:11 -0400 Subject: [PATCH 06/21] Add mgmt job launch --- awx/ui_next/src/api/models/Jobs.js | 4 +- .../src/api/models/SystemJobTemplates.js | 4 ++ awx/ui_next/src/constants.js | 2 +- .../ManagementJobList/ManagementJobList.jsx | 26 +++++-- .../ManagementJobListItem.jsx | 69 +++++++++++++++---- 5 files changed, 81 insertions(+), 24 deletions(-) diff --git a/awx/ui_next/src/api/models/Jobs.js b/awx/ui_next/src/api/models/Jobs.js index fc9bbb2334..db28e172b6 100644 --- a/awx/ui_next/src/api/models/Jobs.js +++ b/awx/ui_next/src/api/models/Jobs.js @@ -9,8 +9,8 @@ const getBaseURL = type => { case 'project': case 'project_update': return '/project_updates/'; - case 'system': - case 'system_job': + case 'management': + case 'management_job': return '/system_jobs/'; case 'inventory': case 'inventory_update': diff --git a/awx/ui_next/src/api/models/SystemJobTemplates.js b/awx/ui_next/src/api/models/SystemJobTemplates.js index 7520a3a3d3..5e8b395821 100644 --- a/awx/ui_next/src/api/models/SystemJobTemplates.js +++ b/awx/ui_next/src/api/models/SystemJobTemplates.js @@ -15,6 +15,10 @@ class SystemJobTemplates extends Mixins { return this.http.get(path).then(({ data }) => data); } + + launch(id, data) { + return this.http.post(`${this.baseUrl}${id}/launch/`, data); + } } export default SystemJobTemplates; diff --git a/awx/ui_next/src/constants.js b/awx/ui_next/src/constants.js index 663814439e..f157c26d49 100644 --- a/awx/ui_next/src/constants.js +++ b/awx/ui_next/src/constants.js @@ -2,7 +2,7 @@ export const JOB_TYPE_URL_SEGMENTS = { job: 'playbook', project_update: 'project', - system_job: 'system', + system_job: 'management', inventory_update: 'inventory', ad_hoc_command: 'command', workflow_job: 'workflow', diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx index 89756a43ee..1949e04169 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx @@ -1,19 +1,21 @@ -import React, { useEffect, useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { t } from '@lingui/macro'; import { withI18n } from '@lingui/react'; import { useLocation } from 'react-router-dom'; import { Card, PageSection } from '@patternfly/react-core'; import { SystemJobTemplatesAPI } from '../../../api'; -import { useConfig } from '../../../contexts/Config'; -import { getQSConfig, parseQueryString } from '../../../util/qs'; -import useRequest from '../../../util/useRequest'; +import AlertModal from '../../../components/AlertModal'; import DatalistToolbar from '../../../components/DataListToolbar'; +import ErrorDetail from '../../../components/ErrorDetail'; import PaginatedDataList from '../../../components/PaginatedDataList'; +import { useConfig } from '../../../contexts/Config'; +import { parseQueryString, getQSConfig } from '../../../util/qs'; +import useRequest from '../../../util/useRequest'; import ManagementJobListItem from './ManagementJobListItem'; -const QS_CONFIG = getQSConfig('management_jobs', { +const QS_CONFIG = getQSConfig('system_job_templates', { page: 1, page_size: 20, }); @@ -46,6 +48,7 @@ const loadManagementJobs = async search => { function ManagementJobList({ i18n }) { const { search } = useLocation(); const { me } = useConfig(); + const [launchError, setLaunchError] = useState(null); const { request, @@ -85,15 +88,26 @@ function ManagementJobList({ i18n }) { )} renderItem={({ id, name, description }) => ( )} /> + setLaunchError(null)} + > + {i18n._(t`Failed to launch job.`)} + + ); } diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx index 7dd31d4ef0..02d898ca08 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx @@ -1,32 +1,55 @@ -import React from 'react'; +import React, { useState } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Link } from 'react-router-dom'; +import { Link, useHistory } from 'react-router-dom'; import { Button, DataListAction as _DataListAction, + DataListCell, DataListItem, DataListItemRow, DataListItemCells, Tooltip, } from '@patternfly/react-core'; -import { PencilAltIcon } from '@patternfly/react-icons'; +import { PencilAltIcon, RocketIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; -import DataListCell from '../../../components/DataListCell'; +import { SystemJobTemplatesAPI } from '../../../api'; const DataListAction = styled(_DataListAction)` align-items: center; display: grid; grid-gap: 16px; - grid-template-columns: 40px; + grid-template-columns: repeat(2, 40px); `; -function ManagementJobListItem({ i18n, isSuperUser, id, name, description }) { +function ManagementJobListItem({ + i18n, + onLaunchError, + isSuperUser, + id, + name, + description, +}) { const detailsUrl = `/management_jobs/${id}/details`; const editUrl = `/management_jobs/${id}/edit`; const labelId = `mgmt-job-action-${id}`; + const history = useHistory(); + const [isLaunchLoading, setIsLaunchLoading] = useState(false); + + const handleLaunch = async () => { + setIsLaunchLoading(true); + try { + const { data } = await SystemJobTemplatesAPI.launch(id); + history.push(`/jobs/management/${data.id}/output`); + } catch (error) { + onLaunchError(error); + } finally { + setIsLaunchLoading(false); + } + }; + return ( @@ -54,16 +77,32 @@ function ManagementJobListItem({ i18n, isSuperUser, id, name, description }) { id={labelId} > {isSuperUser ? ( - - - + + + + + + ) : null} From a0bdf8cdae09f642088417428fdc3d0375f8ce15 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Fri, 11 Sep 2020 15:28:49 -0400 Subject: [PATCH 07/21] Add default sysjob days --- awx/api/serializers.py | 11 +++- awx/api/views/__init__.py | 7 ++- .../migrations/0124_sysjob_default_days.py | 36 +++++++++++++ awx/main/models/jobs.py | 54 +++++++++++++++---- .../functional/api/test_job_runtime_params.py | 28 +++++++++- awx/main/tests/functional/conftest.py | 5 +- 6 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 awx/main/migrations/0124_sysjob_default_days.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index ecce831a19..c0d738207b 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3335,9 +3335,11 @@ class AdHocCommandRelaunchSerializer(AdHocCommandSerializer): class SystemJobTemplateSerializer(UnifiedJobTemplateSerializer): + has_configurable_retention = serializers.BooleanField() + class Meta: model = SystemJobTemplate - fields = ('*', 'job_type',) + fields = ('*', 'job_type', 'has_configurable_retention', 'default_days',) def get_related(self, obj): res = super(SystemJobTemplateSerializer, self).get_related(obj) @@ -3348,10 +3350,15 @@ class SystemJobTemplateSerializer(UnifiedJobTemplateSerializer): notification_templates_started = self.reverse('api:system_job_template_notification_templates_started_list', kwargs={'pk': obj.pk}), notification_templates_success = self.reverse('api:system_job_template_notification_templates_success_list', kwargs={'pk': obj.pk}), notification_templates_error = self.reverse('api:system_job_template_notification_templates_error_list', kwargs={'pk': obj.pk}), - )) return res + def to_representation(self, obj): + result = super(SystemJobTemplateSerializer, self).to_representation(obj) + if not obj.has_configurable_retention: + del result['default_days'] + return result + class SystemJobSerializer(UnifiedJobSerializer): diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 43e845af0c..29b26c120d 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -82,6 +82,7 @@ from awx.main.utils import ( get_object_or_400, getattrd, get_pk_from_dict, + parse_yaml_or_json, schedule_task_manager, ignore_inventory_computed_fields, set_environ @@ -3440,7 +3441,11 @@ class SystemJobTemplateLaunch(GenericAPIView): def post(self, request, *args, **kwargs): obj = self.get_object() - new_job = obj.create_unified_job(extra_vars=request.data.get('extra_vars', {})) + extra_vars = parse_yaml_or_json(request.data.get('extra_vars', {})) + if obj.has_configurable_retention and obj.default_days is not None: + extra_vars.setdefault('days', obj.default_days) + + new_job = obj.create_unified_job(extra_vars=extra_vars) new_job.signal_start() data = OrderedDict() data['system_job'] = new_job.id diff --git a/awx/main/migrations/0124_sysjob_default_days.py b/awx/main/migrations/0124_sysjob_default_days.py new file mode 100644 index 0000000000..1bba82b69c --- /dev/null +++ b/awx/main/migrations/0124_sysjob_default_days.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- + +from django.db import migrations, models + +from awx.main.utils.common import set_current_apps +from awx.main.models import SystemJobTemplate + + +def set_default_days(apps, schema_editor): + set_current_apps(apps) + for sys_template in SystemJobTemplate.objects.all(): + if sys_template.has_configurable_retention: + if sys_template.default_days is None: + sys_template.default_days = 30 + sys_template.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0123_drop_hg_support'), + ] + + operations = [ + migrations.AddField( + model_name='systemjob', + name='default_days', + field=models.PositiveIntegerField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name='systemjobtemplate', + name='default_days', + field=models.PositiveIntegerField(blank=True, default=None, null=True), + ), + migrations.RunPython(set_default_days), + ] diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 638954e53c..d1c56823bd 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -1142,22 +1142,58 @@ class SystemJobOptions(BaseModel): Common fields for SystemJobTemplate and SystemJob. ''' - SYSTEM_JOB_TYPE = [ - ('cleanup_jobs', _('Remove jobs older than a certain number of days')), - ('cleanup_activitystream', _('Remove activity stream entries older than a certain number of days')), - ('cleanup_sessions', _('Removes expired browser sessions from the database')), - ('cleanup_tokens', _('Removes expired OAuth 2 access tokens and refresh tokens')) - ] - class Meta: abstract = True + SYSTEM_CLEANUP_JOBS = ( + 'cleanup_jobs', + _('Remove jobs older than a certain number of days') + ) + SYSTEM_CLEANUP_ACTIVITY = ( + 'cleanup_activitystream', + _('Remove activity stream entries older than a certain number of days') + ) + SYSTEM_CLEANUP_SESSIONS = ( + 'cleanup_sessions', + _('Removes expired browser sessions from the database') + ) + SYSTEM_CLEANUP_TOKENS = ( + 'cleanup_tokens', + _('Removes expired OAuth 2 access tokens and refresh tokens') + ) + + SYSTEM_JOB_TYPES = ( + SYSTEM_CLEANUP_JOBS, + SYSTEM_CLEANUP_ACTIVITY, + SYSTEM_CLEANUP_SESSIONS, + SYSTEM_CLEANUP_TOKENS, + ) + CONFIGURABLE_RETENTION_TYPES = ( + SYSTEM_CLEANUP_JOBS, + SYSTEM_CLEANUP_ACTIVITY, + ) + job_type = models.CharField( max_length=32, - choices=SYSTEM_JOB_TYPE, + choices=SYSTEM_JOB_TYPES, blank=True, default='', ) + default_days = models.PositiveIntegerField( + blank=True, + null=True, + default=None, + ) + + @property + def has_configurable_retention(self): + return self.job_type in (name for (name, _) in SystemJobTemplate.CONFIGURABLE_RETENTION_TYPES) + + def clean_default_days(self): + if not self.has_configurable_retention: + if self.default_days is not None: + raise ValidationError(_(f'Data retention isn\'t configurable for type {self.job_type}')) + return self.default_days class SystemJobTemplate(UnifiedJobTemplate, SystemJobOptions): @@ -1221,7 +1257,7 @@ class SystemJobTemplate(UnifiedJobTemplate, SystemJobOptions): for key in unallowed_vars: rejected[key] = data.pop(key) - if self.job_type in ('cleanup_jobs', 'cleanup_activitystream'): + if self.has_configurable_retention: if 'days' in data: try: if isinstance(data['days'], (bool, type(None))): diff --git a/awx/main/tests/functional/api/test_job_runtime_params.py b/awx/main/tests/functional/api/test_job_runtime_params.py index 80b2fcadfb..78a1e3ba11 100644 --- a/awx/main/tests/functional/api/test_job_runtime_params.py +++ b/awx/main/tests/functional/api/test_job_runtime_params.py @@ -1,3 +1,4 @@ +from collections import namedtuple from unittest import mock import pytest import yaml @@ -6,7 +7,7 @@ import json from awx.api.serializers import JobLaunchSerializer from awx.main.models.credential import Credential from awx.main.models.inventory import Inventory, Host -from awx.main.models.jobs import Job, JobTemplate, UnifiedJobTemplate +from awx.main.models.jobs import Job, JobTemplate, UnifiedJobTemplate, SystemJob from awx.api.versioning import reverse @@ -696,3 +697,28 @@ def test_callback_extra_var_takes_priority_over_host_name(mocker, get, job_templ r = get(reverse('api:job_template_callback', kwargs={'pk': job_template.pk}), user=admin_user, expect=200) assert not r.data['matching_hosts'] + + +@pytest.mark.django_db +@pytest.mark.parametrize('default_days,launch_vars,expected_vars', [ + (9000, {}, {'days': 9000}), + (None, {}, {}), + (9000, {'days': 9001}, {'days': 9001}), + (None, {'days': 9001}, {'days': 9001}), +]) +def test_system_job_launch_default_retention(system_job_template, admin_user, post, + default_days, launch_vars, expected_vars): + system_job_template.default_days = default_days + system_job_template.save() + launch_url = reverse( + 'api:system_job_template_launch', + kwargs={'pk': system_job_template.pk} + ) + mock_stdout = namedtuple('MockHandle', ['read'])(lambda: '') + with mock.patch.object(SystemJob, 'result_stdout_raw_handle', return_value=mock_stdout): + with mock.patch.object(SystemJob, 'signal_start') as signal_start: + res = post(launch_url, {'extra_vars': launch_vars}, admin_user, expect=201) + signal_start.assert_called() + + res_vars = SystemJob.objects.get(id=res.data['id']).extra_vars + assert json.loads(res_vars) == expected_vars diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 7111950003..9154abae79 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -747,7 +747,10 @@ def workflow_job_factory(workflow_job_template, admin): @pytest.fixture def system_job_template(): - sys_jt = SystemJobTemplate(name='test-system_job_template', job_type='cleanup_jobs') + sys_jt = SystemJobTemplate( + name='test-system_job_template', + job_type=SystemJobTemplate.SYSTEM_CLEANUP_JOBS[0] + ) sys_jt.save() return sys_jt From 4c92d025401b778c713da2d439cdc0b4181df041 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 15 Sep 2020 13:44:09 -0400 Subject: [PATCH 08/21] Add mgmt job details --- .../ManagementJobDetails.jsx | 105 +++++++++++++++++- 1 file changed, 101 insertions(+), 4 deletions(-) diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/ManagementJobDetails.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/ManagementJobDetails.jsx index 2ed939563a..ebc6393b18 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/ManagementJobDetails.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/ManagementJobDetails.jsx @@ -1,10 +1,107 @@ -import React from 'react'; +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 { CardBody } from '../../../components/Card'; +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() { - return Management Job Details; +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 ( + <> + + + + + {managementJob?.has_configurable_retention ? ( + + ) : null} + + + + + {me?.is_superuser && managementJob?.has_configurable_retention ? ( + + ) : null} + {me?.is_superuser ? ( + + ) : null} + + + setLaunchError(null)} + > + {i18n._(t`Failed to launch job.`)} + + + + ); } export default withI18n()(ManagementJobDetails); From a95e554a16b9e9e1a537280e4bfa0fb567103507 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 15 Sep 2020 14:01:02 -0400 Subject: [PATCH 09/21] Only render edit control if editable --- .../ManagementJobList/ManagementJobList.jsx | 8 +++++- .../ManagementJobListItem.jsx | 26 ++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx index 1949e04169..e7a0bfccc1 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx @@ -86,13 +86,19 @@ function ManagementJobList({ i18n }) { qsConfig={QS_CONFIG} /> )} - renderItem={({ id, name, description }) => ( + renderItem={({ + id, + name, + description, + has_configurable_retention, + }) => ( )} diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx index 02d898ca08..5969397952 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx @@ -26,6 +26,7 @@ const DataListAction = styled(_DataListAction)` function ManagementJobListItem({ i18n, onLaunchError, + isConfigurable, isSuperUser, id, name, @@ -91,17 +92,22 @@ function ManagementJobListItem({ - - - + + + ) : null} ) : null} From a07b1a19f3ea003fbebb6fb17de84f92e21efaf6 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 3 Feb 2021 14:59:32 -0500 Subject: [PATCH 10/21] Add system prompt and config --- awx/api/serializers.py | 7 + awx/api/views/__init__.py | 2 +- .../ManagementJobEdit/ManagementJobEdit.jsx | 70 +++++++- .../LaunchManagementPrompt.jsx | 83 +++++++++ .../ManagementJobList/ManagementJobList.jsx | 2 + .../ManagementJobListItem.jsx | 168 +++++++++++------- 6 files changed, 267 insertions(+), 65 deletions(-) create mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.jsx diff --git a/awx/api/serializers.py b/awx/api/serializers.py index c0d738207b..82af53b8b6 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3359,6 +3359,13 @@ class SystemJobTemplateSerializer(UnifiedJobTemplateSerializer): del result['default_days'] return result + def __init__(self, *args, **kwargs): + super(SystemJobTemplateSerializer, self).__init__(*args, **kwargs) + for field_name, field_instance in self.fields.items(): + if field_name != 'default_days': + field_instance.read_only = True + + class SystemJobSerializer(UnifiedJobSerializer): diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 29b26c120d..f532bab606 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3423,7 +3423,7 @@ class SystemJobTemplateList(ListAPIView): return super(SystemJobTemplateList, self).get(request, *args, **kwargs) -class SystemJobTemplateDetail(RetrieveAPIView): +class SystemJobTemplateDetail(RetrieveUpdateAPIView): model = models.SystemJobTemplate serializer_class = serializers.SystemJobTemplateSerializer diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/ManagementJobEdit.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/ManagementJobEdit.jsx index 9ddff5539a..17908d9894 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/ManagementJobEdit.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/ManagementJobEdit.jsx @@ -1,9 +1,71 @@ -import React from 'react'; +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() { - return Management Job Edit; +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 ( + + {managementJob?.default_days ? ( + + {formik => ( +
+ + + + + +
+ )} +
+ ) : null} +
+ ); } -export default ManagementJobEdit; +export default withI18n()(ManagementJobEdit); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.jsx new file mode 100644 index 0000000000..ad010be710 --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.jsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Button, TextInput, Tooltip } from '@patternfly/react-core'; +import { RocketIcon } from '@patternfly/react-icons'; + +import AlertModal from '../../../components/AlertModal'; + +const clamp = (val, min, max) => { + if (val < min) { + return min; + } + if (val > max) { + return max; + } + return val; +}; + +function LaunchManagementPrompt({ + i18n, + isOpen, + isLoading, + onClick, + onClose, + onConfirm, + defaultDays, +}) { + const [dataRetention, setDataRetention] = useState(defaultDays); + return ( + <> + + + + onConfirm(dataRetention)} + > + {i18n._(t`Launch`)} + , + , + ]} + > + {i18n._(t`Set how many days of data should be retained.`)} + + setDataRetention(clamp(value, 0, Number.MAX_SAFE_INTEGER)) + } + aria-label={i18n._(t`Launch`)} + /> + + + ); +} + +export default withI18n()(LaunchManagementPrompt); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx index e7a0bfccc1..1a54713c60 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx @@ -91,6 +91,7 @@ function ManagementJobList({ i18n }) { name, description, has_configurable_retention, + default_days, }) => ( )} diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx index 5969397952..79c0e25ea1 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx @@ -11,10 +11,13 @@ import { DataListItemCells, Tooltip, } from '@patternfly/react-core'; -import { PencilAltIcon, RocketIcon } from '@patternfly/react-icons'; +import { RocketIcon, PencilAltIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; import { SystemJobTemplatesAPI } from '../../../api'; +import AlertModal from '../../../components/AlertModal'; +import ErrorDetail from '../../../components/ErrorDetail'; +import LaunchManagementPrompt from './LaunchManagementPrompt'; const DataListAction = styled(_DataListAction)` align-items: center; @@ -31,6 +34,7 @@ function ManagementJobListItem({ id, name, description, + defaultDays, }) { const detailsUrl = `/management_jobs/${id}/details`; const editUrl = `/management_jobs/${id}/edit`; @@ -39,6 +43,28 @@ function ManagementJobListItem({ const history = useHistory(); const [isLaunchLoading, setIsLaunchLoading] = useState(false); + const [isManagementPromptOpen, setIsManagementPromptOpen] = useState(false); + const [isManagementPromptLoading, setIsManagementPromptLoading] = useState( + false + ); + const [managementPromptError, setManagementPromptError] = useState(null); + const handleManagementPromptClick = () => setIsManagementPromptOpen(true); + const handleManagementPromptClose = () => setIsManagementPromptOpen(false); + + const handleManagementPromptConfirm = async days => { + setIsManagementPromptLoading(true); + try { + const { data } = await SystemJobTemplatesAPI.launch(id, { + extra_vars: { days }, + }); + history.push(`/jobs/management/${data.id}/output`); + } catch (error) { + setManagementPromptError(error); + } finally { + setIsManagementPromptLoading(false); + } + }; + const handleLaunch = async () => { setIsLaunchLoading(true); try { @@ -52,67 +78,89 @@ function ManagementJobListItem({ }; return ( - - - - - {name} - - , - - {i18n._(t`Description:`)} {description} - , - ]} - /> - - {isSuperUser ? ( - <> - + + + - - - {isConfigurable ? ( - - + + + ) : ( + - - - - ) : null} - - ) : null} - - - + + + )}{' '} + + ) : null} + + +
+ {managementPromptError && ( + <> + setManagementPromptError(null)} + title={i18n._(t`Management job launch error`)} + label={i18n._(t`Management job launch error`)} + > + + + + )} + ); } From a00c8920ce56c5ef67681626a121f1911f647f27 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 17 Feb 2021 08:04:29 -0500 Subject: [PATCH 11/21] Remove default sysjob days --- awx/api/serializers.py | 18 +------ awx/api/views/__init__.py | 9 +--- .../migrations/0124_sysjob_default_days.py | 36 ------------- awx/main/models/jobs.py | 54 ++++--------------- .../functional/api/test_job_runtime_params.py | 28 +--------- awx/main/tests/functional/conftest.py | 5 +- 6 files changed, 15 insertions(+), 135 deletions(-) delete mode 100644 awx/main/migrations/0124_sysjob_default_days.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 82af53b8b6..ecce831a19 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3335,11 +3335,9 @@ class AdHocCommandRelaunchSerializer(AdHocCommandSerializer): class SystemJobTemplateSerializer(UnifiedJobTemplateSerializer): - has_configurable_retention = serializers.BooleanField() - class Meta: model = SystemJobTemplate - fields = ('*', 'job_type', 'has_configurable_retention', 'default_days',) + fields = ('*', 'job_type',) def get_related(self, obj): res = super(SystemJobTemplateSerializer, self).get_related(obj) @@ -3350,22 +3348,10 @@ class SystemJobTemplateSerializer(UnifiedJobTemplateSerializer): notification_templates_started = self.reverse('api:system_job_template_notification_templates_started_list', kwargs={'pk': obj.pk}), notification_templates_success = self.reverse('api:system_job_template_notification_templates_success_list', kwargs={'pk': obj.pk}), notification_templates_error = self.reverse('api:system_job_template_notification_templates_error_list', kwargs={'pk': obj.pk}), + )) return res - def to_representation(self, obj): - result = super(SystemJobTemplateSerializer, self).to_representation(obj) - if not obj.has_configurable_retention: - del result['default_days'] - return result - - def __init__(self, *args, **kwargs): - super(SystemJobTemplateSerializer, self).__init__(*args, **kwargs) - for field_name, field_instance in self.fields.items(): - if field_name != 'default_days': - field_instance.read_only = True - - class SystemJobSerializer(UnifiedJobSerializer): diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index f532bab606..43e845af0c 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -82,7 +82,6 @@ from awx.main.utils import ( get_object_or_400, getattrd, get_pk_from_dict, - parse_yaml_or_json, schedule_task_manager, ignore_inventory_computed_fields, set_environ @@ -3423,7 +3422,7 @@ class SystemJobTemplateList(ListAPIView): return super(SystemJobTemplateList, self).get(request, *args, **kwargs) -class SystemJobTemplateDetail(RetrieveUpdateAPIView): +class SystemJobTemplateDetail(RetrieveAPIView): model = models.SystemJobTemplate serializer_class = serializers.SystemJobTemplateSerializer @@ -3441,11 +3440,7 @@ class SystemJobTemplateLaunch(GenericAPIView): def post(self, request, *args, **kwargs): obj = self.get_object() - extra_vars = parse_yaml_or_json(request.data.get('extra_vars', {})) - if obj.has_configurable_retention and obj.default_days is not None: - extra_vars.setdefault('days', obj.default_days) - - new_job = obj.create_unified_job(extra_vars=extra_vars) + new_job = obj.create_unified_job(extra_vars=request.data.get('extra_vars', {})) new_job.signal_start() data = OrderedDict() data['system_job'] = new_job.id diff --git a/awx/main/migrations/0124_sysjob_default_days.py b/awx/main/migrations/0124_sysjob_default_days.py deleted file mode 100644 index 1bba82b69c..0000000000 --- a/awx/main/migrations/0124_sysjob_default_days.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- coding: utf-8 -*- - -from django.db import migrations, models - -from awx.main.utils.common import set_current_apps -from awx.main.models import SystemJobTemplate - - -def set_default_days(apps, schema_editor): - set_current_apps(apps) - for sys_template in SystemJobTemplate.objects.all(): - if sys_template.has_configurable_retention: - if sys_template.default_days is None: - sys_template.default_days = 30 - sys_template.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ('main', '0123_drop_hg_support'), - ] - - operations = [ - migrations.AddField( - model_name='systemjob', - name='default_days', - field=models.PositiveIntegerField(blank=True, default=None, null=True), - ), - migrations.AddField( - model_name='systemjobtemplate', - name='default_days', - field=models.PositiveIntegerField(blank=True, default=None, null=True), - ), - migrations.RunPython(set_default_days), - ] diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index d1c56823bd..638954e53c 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -1142,58 +1142,22 @@ class SystemJobOptions(BaseModel): Common fields for SystemJobTemplate and SystemJob. ''' + SYSTEM_JOB_TYPE = [ + ('cleanup_jobs', _('Remove jobs older than a certain number of days')), + ('cleanup_activitystream', _('Remove activity stream entries older than a certain number of days')), + ('cleanup_sessions', _('Removes expired browser sessions from the database')), + ('cleanup_tokens', _('Removes expired OAuth 2 access tokens and refresh tokens')) + ] + class Meta: abstract = True - SYSTEM_CLEANUP_JOBS = ( - 'cleanup_jobs', - _('Remove jobs older than a certain number of days') - ) - SYSTEM_CLEANUP_ACTIVITY = ( - 'cleanup_activitystream', - _('Remove activity stream entries older than a certain number of days') - ) - SYSTEM_CLEANUP_SESSIONS = ( - 'cleanup_sessions', - _('Removes expired browser sessions from the database') - ) - SYSTEM_CLEANUP_TOKENS = ( - 'cleanup_tokens', - _('Removes expired OAuth 2 access tokens and refresh tokens') - ) - - SYSTEM_JOB_TYPES = ( - SYSTEM_CLEANUP_JOBS, - SYSTEM_CLEANUP_ACTIVITY, - SYSTEM_CLEANUP_SESSIONS, - SYSTEM_CLEANUP_TOKENS, - ) - CONFIGURABLE_RETENTION_TYPES = ( - SYSTEM_CLEANUP_JOBS, - SYSTEM_CLEANUP_ACTIVITY, - ) - job_type = models.CharField( max_length=32, - choices=SYSTEM_JOB_TYPES, + choices=SYSTEM_JOB_TYPE, blank=True, default='', ) - default_days = models.PositiveIntegerField( - blank=True, - null=True, - default=None, - ) - - @property - def has_configurable_retention(self): - return self.job_type in (name for (name, _) in SystemJobTemplate.CONFIGURABLE_RETENTION_TYPES) - - def clean_default_days(self): - if not self.has_configurable_retention: - if self.default_days is not None: - raise ValidationError(_(f'Data retention isn\'t configurable for type {self.job_type}')) - return self.default_days class SystemJobTemplate(UnifiedJobTemplate, SystemJobOptions): @@ -1257,7 +1221,7 @@ class SystemJobTemplate(UnifiedJobTemplate, SystemJobOptions): for key in unallowed_vars: rejected[key] = data.pop(key) - if self.has_configurable_retention: + if self.job_type in ('cleanup_jobs', 'cleanup_activitystream'): if 'days' in data: try: if isinstance(data['days'], (bool, type(None))): diff --git a/awx/main/tests/functional/api/test_job_runtime_params.py b/awx/main/tests/functional/api/test_job_runtime_params.py index 78a1e3ba11..80b2fcadfb 100644 --- a/awx/main/tests/functional/api/test_job_runtime_params.py +++ b/awx/main/tests/functional/api/test_job_runtime_params.py @@ -1,4 +1,3 @@ -from collections import namedtuple from unittest import mock import pytest import yaml @@ -7,7 +6,7 @@ import json from awx.api.serializers import JobLaunchSerializer from awx.main.models.credential import Credential from awx.main.models.inventory import Inventory, Host -from awx.main.models.jobs import Job, JobTemplate, UnifiedJobTemplate, SystemJob +from awx.main.models.jobs import Job, JobTemplate, UnifiedJobTemplate from awx.api.versioning import reverse @@ -697,28 +696,3 @@ def test_callback_extra_var_takes_priority_over_host_name(mocker, get, job_templ r = get(reverse('api:job_template_callback', kwargs={'pk': job_template.pk}), user=admin_user, expect=200) assert not r.data['matching_hosts'] - - -@pytest.mark.django_db -@pytest.mark.parametrize('default_days,launch_vars,expected_vars', [ - (9000, {}, {'days': 9000}), - (None, {}, {}), - (9000, {'days': 9001}, {'days': 9001}), - (None, {'days': 9001}, {'days': 9001}), -]) -def test_system_job_launch_default_retention(system_job_template, admin_user, post, - default_days, launch_vars, expected_vars): - system_job_template.default_days = default_days - system_job_template.save() - launch_url = reverse( - 'api:system_job_template_launch', - kwargs={'pk': system_job_template.pk} - ) - mock_stdout = namedtuple('MockHandle', ['read'])(lambda: '') - with mock.patch.object(SystemJob, 'result_stdout_raw_handle', return_value=mock_stdout): - with mock.patch.object(SystemJob, 'signal_start') as signal_start: - res = post(launch_url, {'extra_vars': launch_vars}, admin_user, expect=201) - signal_start.assert_called() - - res_vars = SystemJob.objects.get(id=res.data['id']).extra_vars - assert json.loads(res_vars) == expected_vars diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 9154abae79..7111950003 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -747,10 +747,7 @@ def workflow_job_factory(workflow_job_template, admin): @pytest.fixture def system_job_template(): - sys_jt = SystemJobTemplate( - name='test-system_job_template', - job_type=SystemJobTemplate.SYSTEM_CLEANUP_JOBS[0] - ) + sys_jt = SystemJobTemplate(name='test-system_job_template', job_type='cleanup_jobs') sys_jt.save() return sys_jt From 83b449fd3062d75c974eff145366d7947d7d8e61 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 17 Feb 2021 10:28:01 -0500 Subject: [PATCH 12/21] 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 --- CHANGELOG.md | 1 + .../src/components/Schedule/Schedule.jsx | 15 ++- .../ScheduleDetail/ScheduleDetail.jsx | 16 ++- .../Schedule/ScheduleEdit/ScheduleEdit.jsx | 20 +++- .../Schedule/shared/ScheduleForm.jsx | 31 ++++- .../screens/ManagementJob/ManagementJob.jsx | 86 ++++++-------- .../ManagementJobDetails.jsx | 107 ----------------- .../ManagementJobDetails/index.js | 1 - .../ManagementJobEdit/ManagementJobEdit.jsx | 71 ----------- .../ManagementJob/ManagementJobEdit/index.js | 1 - .../LaunchManagementPrompt.jsx | 8 +- .../ManagementJobList/ManagementJobList.jsx | 26 ++-- .../ManagementJobList.test.jsx | 111 ++++++++++++++++++ .../ManagementJobListItem.jsx | 107 ++++++----------- .../ManagementJobListItem.test.jsx | 69 +++++++++++ .../screens/ManagementJob/ManagementJobs.jsx | 16 +-- 16 files changed, 354 insertions(+), 332 deletions(-) delete mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/ManagementJobDetails.jsx delete mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobDetails/index.js delete mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/ManagementJobEdit.jsx delete mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobEdit/index.js create mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.test.jsx create mode 100644 awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.test.jsx 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] From df7b168911b765cce61d3c0d0f08aec9c6e85d32 Mon Sep 17 00:00:00 2001 From: Jake McDermott <yo@jakemcdermott.me> Date: Thu, 18 Feb 2021 12:41:45 -0500 Subject: [PATCH 13/21] Add actions column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tiago Góes <tiago.goes2009@gmail.com> --- .../ManagementJob/ManagementJobList/ManagementJobList.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx index 439f42b0dc..0a5fc113dd 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx @@ -93,6 +93,7 @@ function ManagementJobList({ i18n }) { <HeaderRow qsConfig={QS_CONFIG}> <HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell> <HeaderCell>{i18n._(t`Description`)}</HeaderCell> + <HeaderCell>{i18n._(t`Actions`)}</HeaderCell> </HeaderRow> } renderRow={({ id, name, description, job_type }) => ( From b545a6510f08f9e9982a65f5afff2ec319dd8793 Mon Sep 17 00:00:00 2001 From: Jake McDermott <yo@jakemcdermott.me> Date: Tue, 23 Feb 2021 09:30:17 -0500 Subject: [PATCH 14/21] Fix Data retention label --- .../ManagementJob/ManagementJobList/LaunchManagementPrompt.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.jsx index f1a0579814..1eba707195 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.jsx @@ -73,7 +73,7 @@ function LaunchManagementPrompt({ value={dataRetention} type="number" onChange={value => setDataRetention(clamp(value, 0, MAX_RETENTION))} - aria-label={i18n._(t`Launch`)} + aria-label={i18n._(t`Data retention period`)} /> </AlertModal> </> From 4985fb6ffa17464868e9689103608ae850e5c82d Mon Sep 17 00:00:00 2001 From: Jake McDermott <yo@jakemcdermott.me> Date: Tue, 23 Feb 2021 09:37:18 -0500 Subject: [PATCH 15/21] Add case insensitive search for Name --- .../ManagementJob/ManagementJobList/ManagementJobList.jsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx index 0a5fc113dd..47200d42de 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx @@ -82,6 +82,13 @@ function ManagementJobList({ i18n }) { pluralizedItemName={i18n._(t`Management Jobs`)} toolbarSearchableKeys={searchableKeys} toolbarRelatedSearchableKeys={relatedSearchableKeys} + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name__icontains', + isDefault: true, + }, + ]} renderToolbar={props => ( <DatalistToolbar {...props} From 2f56a20484e3a3dfeb6013ed223fc836bbfe5d0c Mon Sep 17 00:00:00 2001 From: Jake McDermott <yo@jakemcdermott.me> Date: Tue, 23 Feb 2021 09:54:06 -0500 Subject: [PATCH 16/21] Add data retention field for schedule creation --- .../src/components/Schedule/Schedule.jsx | 8 +----- .../Schedule/ScheduleAdd/ScheduleAdd.jsx | 27 +++++++++++++++---- .../src/components/Schedule/Schedules.jsx | 9 +++++++ .../screens/ManagementJob/ManagementJob.jsx | 5 +++- 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/awx/ui_next/src/components/Schedule/Schedule.jsx b/awx/ui_next/src/components/Schedule/Schedule.jsx index a725215029..4c1a631769 100644 --- a/awx/ui_next/src/components/Schedule/Schedule.jsx +++ b/awx/ui_next/src/components/Schedule/Schedule.jsx @@ -25,6 +25,7 @@ function Schedule({ resource, launchConfig, surveyConfig, + hasDaysToKeepField, }) { const { scheduleId } = useParams(); @@ -96,13 +97,6 @@ function Schedule({ 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} />} diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx index 81f42f5b72..2c8836f00e 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx @@ -15,7 +15,14 @@ import mergeExtraVars from '../../../util/prompt/mergeExtraVars'; import getSurveyValues from '../../../util/prompt/getSurveyValues'; import { getAddedAndRemoved } from '../../../util/lists'; -function ScheduleAdd({ i18n, resource, apiModel, launchConfig, surveyConfig }) { +function ScheduleAdd({ + i18n, + resource, + apiModel, + launchConfig, + surveyConfig, + hasDaysToKeepField, +}) { const [formSubmitError, setFormSubmitError] = useState(null); const history = useHistory(); const location = useLocation(); @@ -70,13 +77,22 @@ function ScheduleAdd({ i18n, resource, apiModel, launchConfig, surveyConfig }) { try { const rule = new RRule(buildRuleObj(values, i18n)); + const requestData = { + ...submitValues, + rrule: rule.toString().replace(/\n/g, ' '), + }; + + if (Object.keys(values).includes('daysToKeep')) { + if (requestData.extra_data) { + requestData.extra_data.days = values.daysToKeep; + } else { + requestData.extra_data = JSON.stringify({ days: values.daysToKeep }); + } + } const { data: { id: scheduleId }, - } = await apiModel.createSchedule(resource.id, { - ...submitValues, - rrule: rule.toString().replace(/\n/g, ' '), - }); + } = await apiModel.createSchedule(resource.id, requestData); if (credentials?.length > 0) { await Promise.all( added.map(({ id: credentialId }) => @@ -94,6 +110,7 @@ function ScheduleAdd({ i18n, resource, apiModel, launchConfig, surveyConfig }) { <Card> <CardBody> <ScheduleForm + hasDaysToKeepField={hasDaysToKeepField} handleCancel={() => history.push(`${pathRoot}schedules`)} handleSubmit={handleSubmit} submitError={formSubmitError} diff --git a/awx/ui_next/src/components/Schedule/Schedules.jsx b/awx/ui_next/src/components/Schedule/Schedules.jsx index 22f429dd29..f6785d8fa8 100644 --- a/awx/ui_next/src/components/Schedule/Schedules.jsx +++ b/awx/ui_next/src/components/Schedule/Schedules.jsx @@ -16,10 +16,18 @@ function Schedules({ }) { const match = useRouteMatch(); + // 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(resource?.job_type); + return ( <Switch> <Route path={`${match.path}/add`}> <ScheduleAdd + hasDaysToKeepField={hasDaysToKeepField} apiModel={apiModel} resource={resource} launchConfig={launchConfig} @@ -28,6 +36,7 @@ function Schedules({ </Route> <Route key="details" path={`${match.path}/:scheduleId`}> <Schedule + hasDaysToKeepField={hasDaysToKeepField} setBreadcrumb={setBreadcrumb} resource={resource} launchConfig={launchConfig} diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx index 5a97c3b876..3beab58084 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx @@ -140,11 +140,14 @@ function ManagementJob({ i18n, setBreadcrumb }) { ) : null} <Route path={`${basePath}/:id/schedules`}> <Schedules - unifiedJobTemplate={result} + apiModel={SystemJobTemplatesAPI} + resource={result} createSchedule={createSchedule} loadSchedules={loadSchedules} loadScheduleOptions={loadScheduleOptions} setBreadcrumb={setBreadcrumb} + launchConfig={{}} + surveyConfig={{}} /> </Route> </Switch> From a9aa91d9f296557a31ea21a7c141ef8717b92b50 Mon Sep 17 00:00:00 2001 From: Jake McDermott <yo@jakemcdermott.me> Date: Tue, 23 Feb 2021 10:44:31 -0500 Subject: [PATCH 17/21] Remove duplicate notification admin request --- .../components/AppContainer/AppContainer.jsx | 20 +---- .../screens/ManagementJob/ManagementJob.jsx | 86 +++++++++++++------ 2 files changed, 63 insertions(+), 43 deletions(-) diff --git a/awx/ui_next/src/components/AppContainer/AppContainer.jsx b/awx/ui_next/src/components/AppContainer/AppContainer.jsx index bf366b2edb..0abd198c07 100644 --- a/awx/ui_next/src/components/AppContainer/AppContainer.jsx +++ b/awx/ui_next/src/components/AppContainer/AppContainer.jsx @@ -12,7 +12,7 @@ import { t } from '@lingui/macro'; import { withI18n } from '@lingui/react'; import styled from 'styled-components'; -import { ConfigAPI, MeAPI, OrganizationsAPI, RootAPI } from '../../api'; +import { ConfigAPI, MeAPI, RootAPI } from '../../api'; import { ConfigProvider } from '../../contexts/Config'; import { SESSION_TIMEOUT_KEY } from '../../constants'; import { isAuthenticated } from '../../util/auth'; @@ -148,22 +148,8 @@ function AppContainer({ i18n, navRouteConfig = [], children }) { results: [me], }, }, - { - data: { results: notificationAdminResults }, - }, - ] = await Promise.all([ - ConfigAPI.read(), - MeAPI.read(), - OrganizationsAPI.read({ - page_size: 1, - role_level: 'notification_admin_role', - }), - ]); - setConfig({ - ...data, - me, - isNotificationAdmin: Boolean(notificationAdminResults?.length), - }); + ] = await Promise.all([ConfigAPI.read(), MeAPI.read()]); + setConfig({ ...data, me }); setIsReady(true); } catch (err) { if (err.response.status === 401) { diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx index 3beab58084..99d3ddac3e 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Link, Redirect, @@ -13,7 +13,7 @@ import { t } from '@lingui/macro'; import { CaretLeftIcon } from '@patternfly/react-icons'; import { Card, PageSection } from '@patternfly/react-core'; -import { SystemJobTemplatesAPI } from '../../api'; +import { SystemJobTemplatesAPI, OrganizationsAPI } from '../../api'; import ContentError from '../../components/ContentError'; import ContentLoading from '../../components/ContentLoading'; import NotificationList from '../../components/NotificationList'; @@ -28,18 +28,39 @@ function ManagementJob({ i18n, setBreadcrumb }) { const match = useRouteMatch(); const { id } = useParams(); const { pathname } = useLocation(); - const { me, isNotificationAdmin } = useConfig(); + const { me } = useConfig(); - const canReadNotifications = isNotificationAdmin || me?.is_system_auditor; + const [isNotificationAdmin, setIsNotificationAdmin] = useState(false); const { isLoading, error, request, result } = useRequest( - useCallback(async () => SystemJobTemplatesAPI.readDetail(id), [id]) + useCallback( + () => + Promise.all([ + SystemJobTemplatesAPI.readDetail(id), + OrganizationsAPI.read({ + page_size: 1, + role_level: 'notification_admin_role', + }), + ]).then(([systemJobTemplate, notificationRoles]) => ({ + systemJobTemplate, + notificationRoles, + })), + [id] + ) ); useEffect(() => { request(); }, [request, pathname]); + useEffect(() => { + if (!result) return; + setIsNotificationAdmin( + Boolean(result?.notificationRoles?.data?.results?.length) + ); + setBreadcrumb(result); + }, [result, setBreadcrumb, setIsNotificationAdmin]); + useEffect(() => { if (!result) return; @@ -47,18 +68,26 @@ function ManagementJob({ i18n, setBreadcrumb }) { }, [result, setBreadcrumb]); const createSchedule = useCallback( - data => SystemJobTemplatesAPI.createSchedule(result.id, data), + data => + SystemJobTemplatesAPI.createSchedule(result?.systemJobTemplate.id, data), [result] ); const loadSchedules = useCallback( - params => SystemJobTemplatesAPI.readSchedules(result.id, params), + params => + SystemJobTemplatesAPI.readSchedules(result?.systemJobTemplate.id, params), [result] ); const loadScheduleOptions = useCallback( - () => SystemJobTemplatesAPI.readScheduleOptions(result.id), + () => + SystemJobTemplatesAPI.readScheduleOptions(result?.systemJobTemplate.id), [result] ); + const shouldShowNotifications = + result?.systemJobTemplate?.id && + (isNotificationAdmin || me?.is_system_auditor); + const shouldShowSchedules = !!result?.systemJobTemplate?.id; + const tabsArray = [ { id: 99, @@ -70,14 +99,17 @@ function ManagementJob({ i18n, setBreadcrumb }) { </> ), }, - { + ]; + + if (shouldShowSchedules) { + tabsArray.push({ id: 0, name: i18n._(t`Schedules`), link: `${match.url}/schedules`, - }, - ]; + }); + } - if (canReadNotifications) { + if (shouldShowNotifications) { tabsArray.push({ id: 1, name: i18n._(t`Notifications`), @@ -129,27 +161,29 @@ function ManagementJob({ i18n, setBreadcrumb }) { from={`${basePath}/:id`} to={`${basePath}/:id/schedules`} /> - {canReadNotifications ? ( + {shouldShowNotifications ? ( <Route path={`${basePath}/:id/notifications`}> <NotificationList - id={Number(result?.id)} + id={Number(result?.systemJobTemplate?.id)} canToggleNotifications={isNotificationAdmin} apiModel={SystemJobTemplatesAPI} /> </Route> ) : null} - <Route path={`${basePath}/:id/schedules`}> - <Schedules - apiModel={SystemJobTemplatesAPI} - resource={result} - createSchedule={createSchedule} - loadSchedules={loadSchedules} - loadScheduleOptions={loadScheduleOptions} - setBreadcrumb={setBreadcrumb} - launchConfig={{}} - surveyConfig={{}} - /> - </Route> + {shouldShowSchedules ? ( + <Route path={`${basePath}/:id/schedules`}> + <Schedules + apiModel={SystemJobTemplatesAPI} + resource={result.systemJobTemplate} + createSchedule={createSchedule} + loadSchedules={loadSchedules} + loadScheduleOptions={loadScheduleOptions} + setBreadcrumb={setBreadcrumb} + launchConfig={{}} + surveyConfig={{}} + /> + </Route> + ) : null} </Switch> </Card> </PageSection> From a0beb9e4453e6e450b4b6366157ff8758ad60ad0 Mon Sep 17 00:00:00 2001 From: Jake McDermott <yo@jakemcdermott.me> Date: Tue, 23 Feb 2021 10:56:22 -0500 Subject: [PATCH 18/21] Remove instructions to add on empty mgmt jobs --- .../src/components/PaginatedTable/PaginatedTable.jsx | 12 ++++++++---- .../ManagementJobList/ManagementJobList.jsx | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx b/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx index f800b33217..bf70c2acc6 100644 --- a/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx +++ b/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx @@ -37,6 +37,7 @@ function PaginatedTable({ showPageSizeOptions, i18n, renderToolbar, + emptyContentMessage, }) { const history = useHistory(); @@ -73,9 +74,6 @@ function PaginatedTable({ const queryParams = parseQueryString(qsConfig, history.location.search); const dataListLabel = i18n._(t`${pluralizedItemName} List`); - const emptyContentMessage = i18n._( - t`Please add ${pluralizedItemName} to populate this list ` - ); const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `); let Content; @@ -85,7 +83,13 @@ function PaginatedTable({ Content = <ContentError error={contentError} />; } else if (items.length <= 0) { Content = ( - <ContentEmpty title={emptyContentTitle} message={emptyContentMessage} /> + <ContentEmpty + title={emptyContentTitle} + message={ + emptyContentMessage || + i18n._(t`Please add ${pluralizedItemName} to populate this list `) + } + /> ); } else { Content = ( diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx index 47200d42de..69579ef4f0 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx @@ -80,6 +80,7 @@ function ManagementJobList({ i18n }) { items={items} itemCount={count} pluralizedItemName={i18n._(t`Management Jobs`)} + emptyContentMessage={' '} toolbarSearchableKeys={searchableKeys} toolbarRelatedSearchableKeys={relatedSearchableKeys} toolbarSearchColumns={[ From 5d51a4e781f4842400b40556a4eec636d5b4a081 Mon Sep 17 00:00:00 2001 From: Jake McDermott <yo@jakemcdermott.me> Date: Tue, 23 Feb 2021 10:59:29 -0500 Subject: [PATCH 19/21] Fix system job list item key --- awx/ui_next/src/components/JobList/JobListItem.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui_next/src/components/JobList/JobListItem.jsx b/awx/ui_next/src/components/JobList/JobListItem.jsx index bd10bf6467..50f31400e5 100644 --- a/awx/ui_next/src/components/JobList/JobListItem.jsx +++ b/awx/ui_next/src/components/JobList/JobListItem.jsx @@ -32,7 +32,7 @@ function JobListItem({ inventory_update: i18n._(t`Inventory Sync`), job: i18n._(t`Playbook Run`), ad_hoc_command: i18n._(t`Command`), - management_job: i18n._(t`Management Job`), + system_job: i18n._(t`Management Job`), workflow_job: i18n._(t`Workflow Job`), }; From d337948bd69857a3394307eebea1bb3d4c9fac89 Mon Sep 17 00:00:00 2001 From: Jake McDermott <yo@jakemcdermott.me> Date: Tue, 23 Feb 2021 11:26:19 -0500 Subject: [PATCH 20/21] Use system job type as identifier --- .../ManagementJob/ManagementJobList/ManagementJobList.jsx | 1 + .../ManagementJob/ManagementJobList/ManagementJobListItem.jsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx index 69579ef4f0..89c7200a57 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx @@ -109,6 +109,7 @@ function ManagementJobList({ i18n }) { key={id} id={id} name={name} + jobType={job_type} description={description} isSuperUser={me?.is_superuser} isPrompted={['cleanup_activitystream', 'cleanup_jobs'].includes( diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx index 2db3657ad3..06ca1b8dc4 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx @@ -18,6 +18,7 @@ function ManagementJobListItem({ isPrompted, isSuperUser, id, + jobType, name, description, }) { @@ -62,7 +63,7 @@ function ManagementJobListItem({ return ( <> - <Tr id={`mgmt-jobs-row-${id}`}> + <Tr id={`mgmt-jobs-row-${jobType ? jobType.replace('_', '-') : ''}`}> <Td /> <Td dataLabel={i18n._(t`Name`)}> <Link to={`${detailsUrl}`}> From 218b97883d74423deed7d40f28f6cb18b0be2d03 Mon Sep 17 00:00:00 2001 From: Jake McDermott <yo@jakemcdermott.me> Date: Wed, 24 Feb 2021 12:13:15 -0500 Subject: [PATCH 21/21] Fix schedule loading flicker --- awx/ui_next/src/components/Schedule/Schedule.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui_next/src/components/Schedule/Schedule.jsx b/awx/ui_next/src/components/Schedule/Schedule.jsx index 4c1a631769..d0243ac0bd 100644 --- a/awx/ui_next/src/components/Schedule/Schedule.jsx +++ b/awx/ui_next/src/components/Schedule/Schedule.jsx @@ -70,7 +70,7 @@ function Schedule({ }, ]; - if (isLoading) { + if (isLoading || !schedule?.summary_fields?.unified_job_template?.id) { return <ContentLoading />; }