From 61c0beccffbad3ac5ce53049344c1475a1f13479 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 26 Jan 2021 14:58:52 -0500 Subject: [PATCH 1/4] Updates props being passed to Schedules to more accuratly reflect what they are --- .../src/api/models/InventorySources.js | 1 + awx/ui_next/src/api/models/JobTemplates.js | 1 + awx/ui_next/src/api/models/Projects.js | 1 + awx/ui_next/src/api/models/Schedules.js | 13 +++++ .../src/api/models/WorkflowJobTemplates.js | 1 + .../src/components/Schedule/Schedule.jsx | 57 +++++++++---------- .../src/components/Schedule/Schedule.test.jsx | 5 +- .../Schedule/ScheduleAdd/ScheduleAdd.jsx | 11 ++-- .../ScheduleDetail/ScheduleDetail.jsx | 14 +++++ .../Schedule/ScheduleEdit/ScheduleEdit.jsx | 2 +- .../src/components/Schedule/Schedules.jsx | 11 ++-- .../InventorySource/InventorySource.jsx | 9 +-- awx/ui_next/src/screens/Project/Project.jsx | 8 +-- awx/ui_next/src/screens/Template/Template.jsx | 8 +-- .../screens/Template/WorkflowJobTemplate.jsx | 8 +-- 15 files changed, 78 insertions(+), 72 deletions(-) diff --git a/awx/ui_next/src/api/models/InventorySources.js b/awx/ui_next/src/api/models/InventorySources.js index 8d20076ba8..baa2a85cb0 100644 --- a/awx/ui_next/src/api/models/InventorySources.js +++ b/awx/ui_next/src/api/models/InventorySources.js @@ -10,6 +10,7 @@ class InventorySources extends LaunchUpdateMixin( super(http); this.baseUrl = '/api/v2/inventory_sources/'; + this.createSchedule = this.createSchedule.bind(this); this.createSyncStart = this.createSyncStart.bind(this); this.destroyGroups = this.destroyGroups.bind(this); this.destroyHosts = this.destroyHosts.bind(this); diff --git a/awx/ui_next/src/api/models/JobTemplates.js b/awx/ui_next/src/api/models/JobTemplates.js index 44281f1511..da0af7cff5 100644 --- a/awx/ui_next/src/api/models/JobTemplates.js +++ b/awx/ui_next/src/api/models/JobTemplates.js @@ -10,6 +10,7 @@ class JobTemplates extends SchedulesMixin( super(http); this.baseUrl = '/api/v2/job_templates/'; + this.createSchedule = this.createSchedule.bind(this); this.launch = this.launch.bind(this); this.readLaunch = this.readLaunch.bind(this); this.associateLabel = this.associateLabel.bind(this); diff --git a/awx/ui_next/src/api/models/Projects.js b/awx/ui_next/src/api/models/Projects.js index 38879a2bc2..1810bb33e5 100644 --- a/awx/ui_next/src/api/models/Projects.js +++ b/awx/ui_next/src/api/models/Projects.js @@ -16,6 +16,7 @@ class Projects extends SchedulesMixin( this.readPlaybooks = this.readPlaybooks.bind(this); this.readSync = this.readSync.bind(this); this.sync = this.sync.bind(this); + this.createSchedule = this.createSchedule.bind(this); } readAccessList(id, params) { diff --git a/awx/ui_next/src/api/models/Schedules.js b/awx/ui_next/src/api/models/Schedules.js index 7f20e992ae..14b982ba0d 100644 --- a/awx/ui_next/src/api/models/Schedules.js +++ b/awx/ui_next/src/api/models/Schedules.js @@ -14,6 +14,19 @@ class Schedules extends Base { return this.http.get(`${this.baseUrl}${resourceId}/credentials/`, params); } + associateCredential(resourceId, credentialId) { + return this.http.post(`${this.baseUrl}${resourceId}/credentials/`, { + id: credentialId, + }); + } + + disassociateCredential(resourceId, credentialId) { + return this.http.post(`${this.baseUrl}${resourceId}/credentials/`, { + id: credentialId, + disassociate: true, + }); + } + readZoneInfo() { return this.http.get(`${this.baseUrl}zoneinfo/`); } diff --git a/awx/ui_next/src/api/models/WorkflowJobTemplates.js b/awx/ui_next/src/api/models/WorkflowJobTemplates.js index beed5be9ad..9f868534b6 100644 --- a/awx/ui_next/src/api/models/WorkflowJobTemplates.js +++ b/awx/ui_next/src/api/models/WorkflowJobTemplates.js @@ -6,6 +6,7 @@ class WorkflowJobTemplates extends SchedulesMixin(NotificationsMixin(Base)) { constructor(http) { super(http); this.baseUrl = '/api/v2/workflow_job_templates/'; + this.createSchedule = this.createSchedule.bind(this); } readWebhookKey(id) { diff --git a/awx/ui_next/src/components/Schedule/Schedule.jsx b/awx/ui_next/src/components/Schedule/Schedule.jsx index ffa28dd35f..201841048c 100644 --- a/awx/ui_next/src/components/Schedule/Schedule.jsx +++ b/awx/ui_next/src/components/Schedule/Schedule.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { t } from '@lingui/macro'; import { withI18n } from '@lingui/react'; @@ -17,37 +17,39 @@ import ContentLoading from '../ContentLoading'; import ScheduleDetail from './ScheduleDetail'; import ScheduleEdit from './ScheduleEdit'; import { SchedulesAPI } from '../../api'; +import useRequest from '../../util/useRequest'; -function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) { - const [schedule, setSchedule] = useState(null); - const [contentLoading, setContentLoading] = useState(true); - const [contentError, setContentError] = useState(null); +function Schedule({ i18n, setBreadcrumb, resource }) { const { scheduleId } = useParams(); - const location = useLocation(); - const { pathname } = location; + + const { pathname } = useLocation(); + const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); - useEffect(() => { - const loadData = async () => { - try { - const { data } = await SchedulesAPI.readDetail(scheduleId); - setSchedule(data); - } catch (err) { - setContentError(err); - } finally { - setContentLoading(false); - } - }; + const { + isLoading: contentLoading, + error: contentError, + request: loadData, + result: schedule, + } = useRequest( + useCallback(async () => { + const { data } = await SchedulesAPI.readDetail(scheduleId); + return data; + }, [scheduleId]), + null + ); + + useEffect(() => { loadData(); - }, [location.pathname, scheduleId]); + }, [loadData, pathname]); useEffect(() => { if (schedule) { - setBreadcrumb(unifiedJobTemplate, schedule); + setBreadcrumb(resource, schedule); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [schedule, unifiedJobTemplate]); + }, [schedule, resource]); const tabsArray = [ { name: ( @@ -71,8 +73,8 @@ function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) { } if ( - schedule.summary_fields.unified_job_template.id !== - parseInt(unifiedJobTemplate.id, 10) + schedule?.summary_fields.unified_job_template.id !== + parseInt(resource.id, 10) ) { return ( @@ -89,10 +91,7 @@ function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) { let showCardHeader = true; - if ( - !location.pathname.includes('schedules/') || - location.pathname.endsWith('edit') - ) { + if (!pathname.includes('schedules/') || pathname.endsWith('edit')) { showCardHeader = false; } return ( @@ -106,7 +105,7 @@ function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) { /> {schedule && [ - + , - {unifiedJobTemplate && ( + {resource && ( {i18n._(t`View Details`)} )} diff --git a/awx/ui_next/src/components/Schedule/Schedule.test.jsx b/awx/ui_next/src/components/Schedule/Schedule.test.jsx index e3c394cc95..280c6af6bb 100644 --- a/awx/ui_next/src/components/Schedule/Schedule.test.jsx +++ b/awx/ui_next/src/components/Schedule/Schedule.test.jsx @@ -93,10 +93,7 @@ describe('', () => { ( - {}} - unifiedJobTemplate={unifiedJobTemplate} - /> + {}} resource={unifiedJobTemplate} /> )} />, { diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx index 7285e760a2..e6a9b81699 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx @@ -8,7 +8,7 @@ import { CardBody } from '../../Card'; import buildRuleObj from '../shared/buildRuleObj'; import ScheduleForm from '../shared/ScheduleForm'; -function ScheduleAdd({ i18n, createSchedule }) { +function ScheduleAdd({ i18n, resource, apiModel }) { const [formSubmitError, setFormSubmitError] = useState(null); const history = useHistory(); const location = useLocation(); @@ -18,11 +18,8 @@ function ScheduleAdd({ i18n, createSchedule }) { const handleSubmit = async values => { try { const rule = new RRule(buildRuleObj(values, i18n)); - const { - data: { id: scheduleId }, - } = await createSchedule({ - name: values.name, - description: values.description, + + const { id: scheduleId } = await apiModel.createSchedule(resource.id, { rrule: rule.toString().replace(/\n/g, ' '), }); @@ -46,7 +43,7 @@ function ScheduleAdd({ i18n, createSchedule }) { } ScheduleAdd.propTypes = { - createSchedule: func.isRequired, + apiModel: shape({ createSchedule: func.isRequired }).isRequired, }; ScheduleAdd.defaultProps = {}; diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx index 946ac94f55..19ea0495c5 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx @@ -189,6 +189,14 @@ function ScheduleDetail({ schedule, i18n }) { showVerbosityDetail || showVariablesDetail; + const VERBOSITY = { + 0: i18n._(t`0 (Normal)`), + 1: i18n._(t`1 (Verbose)`), + 2: i18n._(t`2 (More Verbose)`), + 3: i18n._(t`3 (Debug)`), + 4: i18n._(t`4 (Connection Debug)`), + }; + if (isLoading) { return ; } @@ -256,6 +264,12 @@ function ScheduleDetail({ schedule, i18n }) { } /> )} + {ask_verbosity_on_launch && ( + + )} {ask_scm_branch_on_launch && ( - + - + - InventorySourcesAPI.createSchedule(source?.id, data); - const loadScheduleOptions = useCallback(() => { return InventorySourcesAPI.readScheduleOptions(source?.id); }, [source]); @@ -160,11 +157,11 @@ function InventorySource({ i18n, inventory, setBreadcrumb, me }) { path="/inventories/inventory/:id/sources/:sourceId/schedules" > + apiModel={InventorySourcesAPI} + setBreadcrumb={schedule => setBreadcrumb(inventory, source, schedule) } - unifiedJobTemplate={source} + resource={source} loadSchedules={loadSchedules} loadScheduleOptions={loadScheduleOptions} /> diff --git a/awx/ui_next/src/screens/Project/Project.jsx b/awx/ui_next/src/screens/Project/Project.jsx index 72341a5de9..5c3a5a7564 100644 --- a/awx/ui_next/src/screens/Project/Project.jsx +++ b/awx/ui_next/src/screens/Project/Project.jsx @@ -78,10 +78,6 @@ function Project({ i18n, setBreadcrumb }) { } }, [project, setBreadcrumb]); - function createSchedule(data) { - return ProjectsAPI.createSchedule(project.id, data); - } - const loadScheduleOptions = useCallback(() => { return ProjectsAPI.readScheduleOptions(project.id); }, [project]); @@ -188,8 +184,8 @@ function Project({ i18n, setBreadcrumb }) { diff --git a/awx/ui_next/src/screens/Template/Template.jsx b/awx/ui_next/src/screens/Template/Template.jsx index 50b238b3b7..5b8233b1af 100644 --- a/awx/ui_next/src/screens/Template/Template.jsx +++ b/awx/ui_next/src/screens/Template/Template.jsx @@ -86,10 +86,6 @@ function Template({ i18n, setBreadcrumb }) { } }, [template, setBreadcrumb]); - const createSchedule = data => { - return JobTemplatesAPI.createSchedule(template.id, data); - }; - const loadScheduleOptions = useCallback(() => { return JobTemplatesAPI.readScheduleOptions(templateId); }, [templateId]); @@ -203,9 +199,9 @@ function Template({ i18n, setBreadcrumb }) { path="/templates/:templateType/:id/schedules" > diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx index ee22010983..8d50bd937c 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx @@ -73,10 +73,6 @@ function WorkflowJobTemplate({ i18n, setBreadcrumb }) { loadTemplateAndRoles(); }, [loadTemplateAndRoles, location.pathname]); - const createSchedule = data => { - return WorkflowJobTemplatesAPI.createSchedule(templateId, data); - }; - const loadScheduleOptions = useCallback(() => { return WorkflowJobTemplatesAPI.readScheduleOptions(templateId); }, [templateId]); @@ -206,9 +202,9 @@ function WorkflowJobTemplate({ i18n, setBreadcrumb }) { path="/templates/:templateType/:id/schedules" > From c608d761a2c3dacb52256dff9a554c11385f900b Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 2 Feb 2021 14:35:31 -0500 Subject: [PATCH 2/4] Adds Prompting for schedule --- .../src/components/JobList/JobListItem.jsx | 8 + .../LaunchPrompt/steps/usePreviewStep.jsx | 5 +- .../src/components/Schedule/Schedule.jsx | 13 +- .../Schedule/ScheduleAdd/ScheduleAdd.jsx | 65 ++- .../Schedule/ScheduleAdd/ScheduleAdd.test.jsx | 219 ++++++++-- .../Schedule/ScheduleEdit/ScheduleEdit.jsx | 66 ++- .../ScheduleEdit/ScheduleEdit.test.jsx | 339 ++++++++++++++- .../Schedule/shared/ScheduleForm.jsx | 236 ++++++++-- .../Schedule/shared/ScheduleForm.test.jsx | 407 +++++++++++++++++- .../shared/SchedulePromptableFields.jsx | 127 ++++++ .../Schedule/shared/useSchedulePromptSteps.js | 102 +++++ .../src/screens/Job/JobDetail/JobDetail.jsx | 5 + .../screens/Job/JobDetail/JobDetail.test.jsx | 2 + 13 files changed, 1492 insertions(+), 102 deletions(-) create mode 100644 awx/ui_next/src/components/Schedule/shared/SchedulePromptableFields.jsx create mode 100644 awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js diff --git a/awx/ui_next/src/components/JobList/JobListItem.jsx b/awx/ui_next/src/components/JobList/JobListItem.jsx index 27047015de..bd10bf6467 100644 --- a/awx/ui_next/src/components/JobList/JobListItem.jsx +++ b/awx/ui_next/src/components/JobList/JobListItem.jsx @@ -160,6 +160,14 @@ function JobListItem({ } /> )} + + {job.job_explanation && ( + + )} diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx index 8a4cc73dde..a53c6d6a6c 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx @@ -11,7 +11,8 @@ export default function usePreviewStep( resource, surveyConfig, hasErrors, - showStep + showStep, + nextButtonText ) { return { step: showStep @@ -31,7 +32,7 @@ export default function usePreviewStep( /> ), enableNext: !hasErrors, - nextButtonText: i18n._(t`Launch`), + nextButtonText: nextButtonText || i18n._(t`Launch`), } : null, initialValues: {}, diff --git a/awx/ui_next/src/components/Schedule/Schedule.jsx b/awx/ui_next/src/components/Schedule/Schedule.jsx index 201841048c..7974ba072b 100644 --- a/awx/ui_next/src/components/Schedule/Schedule.jsx +++ b/awx/ui_next/src/components/Schedule/Schedule.jsx @@ -26,12 +26,7 @@ function Schedule({ i18n, setBreadcrumb, resource }) { const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); - const { - isLoading: contentLoading, - error: contentError, - request: loadData, - result: schedule, - } = useRequest( + const { isLoading, error, request: loadData, result: schedule } = useRequest( useCallback(async () => { const { data } = await SchedulesAPI.readDetail(scheduleId); @@ -68,7 +63,7 @@ function Schedule({ i18n, setBreadcrumb, resource }) { }, ]; - if (contentLoading) { + if (isLoading) { return ; } @@ -85,8 +80,8 @@ function Schedule({ i18n, setBreadcrumb, resource }) { ); } - if (contentError) { - return ; + if (error) { + return ; } let showCardHeader = true; diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx index e6a9b81699..1fd59a91f7 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx @@ -1,12 +1,19 @@ import React, { useState } from 'react'; -import { func } from 'prop-types'; +import { func, shape } from 'prop-types'; import { withI18n } from '@lingui/react'; import { useHistory, useLocation } from 'react-router-dom'; import { RRule } from 'rrule'; import { Card } from '@patternfly/react-core'; +import yaml from 'js-yaml'; import { CardBody } from '../../Card'; +import { parseVariableField } from '../../../util/yaml'; + import buildRuleObj from '../shared/buildRuleObj'; import ScheduleForm from '../shared/ScheduleForm'; +import { SchedulesAPI } from '../../../api'; +import mergeExtraVars from '../../../util/prompt/mergeExtraVars'; +import getSurveyValues from '../../../util/prompt/getSurveyValues'; +import { getAddedAndRemoved } from '../../../util/lists'; function ScheduleAdd({ i18n, resource, apiModel }) { const [formSubmitError, setFormSubmitError] = useState(null); @@ -15,14 +22,63 @@ function ScheduleAdd({ i18n, resource, apiModel }) { const { pathname } = location; const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); - const handleSubmit = async values => { + const handleSubmit = async (values, launchConfig, surveyConfig) => { + const { + inventory, + extra_vars, + originalCredentials, + end, + frequency, + interval, + startDateTime, + timezone, + occurrences, + runOn, + runOnTheDay, + runOnTheMonth, + runOnDayMonth, + runOnDayNumber, + endDateTime, + runOnTheOccurrence, + credentials, + daysOfWeek, + ...submitValues + } = values; + const { added } = getAddedAndRemoved( + resource?.summary_fields.credentials, + credentials + ); + let extraVars; + const surveyValues = getSurveyValues(values); + const initialExtraVars = + launchConfig?.ask_variables_on_launch && (values.extra_vars || '---'); + if (surveyConfig?.spec) { + extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, surveyValues)); + } else { + extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, {})); + } + submitValues.extra_data = extraVars && parseVariableField(extraVars); + delete values.extra_vars; + if (inventory) { + submitValues.inventory = inventory.id; + } + try { const rule = new RRule(buildRuleObj(values, i18n)); - const { id: scheduleId } = await apiModel.createSchedule(resource.id, { + const { + data: { id: scheduleId }, + } = await apiModel.createSchedule(resource.id, { + ...submitValues, rrule: rule.toString().replace(/\n/g, ' '), }); - + if (credentials?.length > 0) { + await Promise.all( + added.map(({ id: credentialId }) => + SchedulesAPI.associateCredential(scheduleId, credentialId) + ) + ); + } history.push(`${pathRoot}schedules/${scheduleId}`); } catch (err) { setFormSubmitError(err); @@ -36,6 +92,7 @@ function ScheduleAdd({ i18n, resource, apiModel }) { handleCancel={() => history.push(`${pathRoot}schedules`)} handleSubmit={handleSubmit} submitError={formSubmitError} + resource={resource} /> diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx index 176462f31e..41f6b02d1d 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx @@ -5,10 +5,12 @@ import { mountWithContexts, waitForElement, } from '../../../../testUtils/enzymeHelpers'; -import { SchedulesAPI } from '../../../api'; +import { SchedulesAPI, JobTemplatesAPI, InventoriesAPI } from '../../../api'; import ScheduleAdd from './ScheduleAdd'; jest.mock('../../../api/models/Schedules'); +jest.mock('../../../api/models/JobTemplates'); +jest.mock('../../../api/models/Inventories'); SchedulesAPI.readZoneInfo.mockResolvedValue({ data: [ @@ -17,22 +19,62 @@ SchedulesAPI.readZoneInfo.mockResolvedValue({ }, ], }); +JobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: true, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: true, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + defaults: { + extra_vars: '---', + diff_mode: false, + limit: '', + job_tags: '', + skip_tags: '', + job_type: 'run', + verbosity: 0, + inventory: { + name: null, + id: null, + }, + scm_branch: '', + }, + }, +}); +JobTemplatesAPI.createSchedule.mockResolvedValue({ data: { id: 3 } }); let wrapper; -const createSchedule = jest.fn().mockImplementation(() => { - return { - data: { - id: 1, - }, - }; -}); - describe('', () => { beforeAll(async () => { await act(async () => { wrapper = mountWithContexts( - + ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); @@ -42,7 +84,7 @@ describe('', () => { }); test('Successfully creates a schedule with repeat frequency: None (run once)', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'none', @@ -52,16 +94,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run once schedule', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T100000 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', }); }); test('Successfully creates a schedule with 10 minute repeat frequency after 10 occurrences', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'after', frequency: 'minute', @@ -72,16 +115,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run every 10 minutes 10 times', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T103000 RRULE:INTERVAL=10;FREQ=MINUTELY;COUNT=10', }); }); test('Successfully creates a schedule with hourly repeat frequency ending on a specific date/time', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'onDate', endDateTime: '2020-03-26T10:45:00', @@ -92,16 +136,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run every hour until date', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=HOURLY;UNTIL=20200326T104500', }); }); test('Successfully creates a schedule with daily repeat frequency', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'day', @@ -111,16 +156,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run daily', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=DAILY', }); }); test('Successfully creates a schedule with weekly repeat frequency on mon/wed/fri', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ daysOfWeek: [RRule.MO, RRule.WE, RRule.FR], description: 'test description', end: 'never', @@ -132,15 +178,16 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run weekly on mon/wed/fri', + extra_data: {}, rrule: `DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=${RRule.MO},${RRule.WE},${RRule.FR}`, }); }); test('Successfully creates a schedule with monthly repeat frequency on the first day of the month', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'month', @@ -153,16 +200,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run on the first day of the month', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200401T104500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYMONTHDAY=1', }); }); test('Successfully creates a schedule with monthly repeat frequency on the last tuesday of the month', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', endDateTime: '2020-03-26T11:00:00', @@ -177,16 +225,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run monthly on the last Tuesday', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200331T110000 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=TU', }); }); test('Successfully creates a schedule with yearly repeat frequency on the first day of March', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -200,16 +249,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Yearly on the first day of March', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200301T000000 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=1', }); }); test('Successfully creates a schedule with yearly repeat frequency on the second Friday in April', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -224,16 +274,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Yearly on the second Friday in April', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=2;BYDAY=FR;BYMONTH=4', }); }); test('Successfully creates a schedule with yearly repeat frequency on the first weekday in October', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -248,11 +299,119 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Yearly on the first weekday in October', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=1;BYDAY=MO,TU,WE,TH,FR;BYMONTH=10', }); }); + + test('should submit prompted data properly', async () => { + InventoriesAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { + name: 'Foo', + id: 1, + url: '', + }, + { + name: 'Bar', + id: 2, + url: '', + }, + ], + }, + }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { + related_search_fields: [], + actions: { + GET: { + filterable: true, + }, + }, + }, + }); + + await act(async () => + wrapper.find('Button[aria-label="Prompt"]').prop('onClick')() + ); + wrapper.update(); + expect( + wrapper + .find('WizardNavItem') + .at(0) + .prop('isCurrent') + ).toBe(true); + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-1"]') + .simulate('change', { + target: { + checked: true, + }, + }); + }); + wrapper.update(); + expect( + wrapper + .find('input[aria-labelledby="check-action-item-1"]') + .prop('checked') + ).toBe(true); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onNext')() + ); + wrapper.update(); + expect( + wrapper + .find('WizardNavItem') + .at(1) + .prop('isCurrent') + ).toBe(true); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onNext')() + ); + wrapper.update(); + expect(wrapper.find('Wizard').length).toBe(0); + // console.log(wrapper.debug()); + await act(async () => { + wrapper.find('Formik').invoke('onSubmit')({ + name: 'Schedule', + end: 'never', + endDateTime: '2021-01-29T14:15:00', + frequency: 'none', + occurrences: 1, + runOn: 'day', + runOnDayMonth: 1, + runOnDayNumber: 1, + runOnTheDay: 'sunday', + runOnTheMonth: 1, + runOnTheOccurrence: 1, + skip_tags: '', + inventory: { name: 'inventory', id: 45 }, + credentials: [ + { name: 'cred 1', id: 10 }, + { name: 'cred 2', id: 20 }, + ], + startDateTime: '2021-01-28T14:15:00', + timezone: 'America/New_York', + }); + }); + wrapper.update(); + + expect(JobTemplatesAPI.createSchedule).toBeCalledWith(700, { + extra_data: {}, + inventory: 45, + name: 'Schedule', + rrule: + 'DTSTART;TZID=America/New_York:20210128T141500 RRULE:COUNT=1;FREQ=MINUTELY', + skip_tags: '', + }); + expect(SchedulesAPI.associateCredential).toBeCalledWith(3, 10); + expect(SchedulesAPI.associateCredential).toBeCalledWith(3, 20); + }); }); diff --git a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx index 34aaf30068..e5faf4dbeb 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx @@ -4,10 +4,16 @@ import { useHistory, useLocation } from 'react-router-dom'; import { RRule } from 'rrule'; import { shape } from 'prop-types'; import { Card } from '@patternfly/react-core'; +import yaml from 'js-yaml'; import { CardBody } from '../../Card'; import { SchedulesAPI } from '../../../api'; import buildRuleObj from '../shared/buildRuleObj'; import ScheduleForm from '../shared/ScheduleForm'; +import { getAddedAndRemoved } from '../../../util/lists'; + +import { parseVariableField } from '../../../util/yaml'; +import mergeExtraVars from '../../../util/prompt/mergeExtraVars'; +import getSurveyValues from '../../../util/prompt/getSurveyValues'; function ScheduleEdit({ i18n, schedule, resource }) { const [formSubmitError, setFormSubmitError] = useState(null); @@ -16,16 +22,69 @@ function ScheduleEdit({ i18n, schedule, resource }) { const { pathname } = location; const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); - const handleSubmit = async values => { + const handleSubmit = async ( + values, + launchConfig, + surveyConfig, + scheduleCredentials = [] + ) => { + const { + inventory, + credentials = [], + end, + frequency, + interval, + startDateTime, + timezone, + occurrences, + runOn, + runOnTheDay, + runOnTheMonth, + runOnDayMonth, + runOnDayNumber, + endDateTime, + runOnTheOccurrence, + daysOfWeek, + ...submitValues + } = values; + const { added, removed } = getAddedAndRemoved( + [...resource?.summary_fields.credentials, ...scheduleCredentials], + credentials + ); + + let extraVars; + const surveyValues = getSurveyValues(values); + const initialExtraVars = + launchConfig?.ask_variables_on_launch && (values.extra_vars || '---'); + if (surveyConfig?.spec) { + extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, surveyValues)); + } else { + extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, {})); + } + submitValues.extra_data = extraVars && parseVariableField(extraVars); + delete values.extra_vars; + if (inventory) { + submitValues.inventory = inventory.id; + } + try { const rule = new RRule(buildRuleObj(values, i18n)); const { data: { id: scheduleId }, } = await SchedulesAPI.update(schedule.id, { - name: values.name, - description: values.description, + ...submitValues, rrule: rule.toString().replace(/\n/g, ' '), }); + if (values.credentials?.length > 0) { + await Promise.all([ + ...removed.map(({ id }) => + SchedulesAPI.disassociateCredential(scheduleId, id) + ), + ...added.map(({ id }) => + SchedulesAPI.associateCredential(scheduleId, id) + ), + ]); + } history.push(`${pathRoot}schedules/${scheduleId}/details`); } catch (err) { @@ -43,6 +102,7 @@ function ScheduleEdit({ i18n, schedule, resource }) { } handleSubmit={handleSubmit} submitError={formSubmitError} + resource={resource} /> diff --git a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx index ed8b2c44e0..404f7334cb 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx @@ -5,10 +5,20 @@ import { mountWithContexts, waitForElement, } from '../../../../testUtils/enzymeHelpers'; -import { SchedulesAPI } from '../../../api'; +import { + SchedulesAPI, + JobTemplatesAPI, + InventoriesAPI, + CredentialsAPI, + CredentialTypesAPI, +} from '../../../api'; import ScheduleEdit from './ScheduleEdit'; jest.mock('../../../api/models/Schedules'); +jest.mock('../../../api/models/JobTemplates'); +jest.mock('../../../api/models/Inventories'); +jest.mock('../../../api/models/Credentials'); +jest.mock('../../../api/models/CredentialTypes'); SchedulesAPI.readZoneInfo.mockResolvedValue({ data: [ @@ -18,6 +28,75 @@ SchedulesAPI.readZoneInfo.mockResolvedValue({ ], }); +JobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: true, + ask_credential_on_launch: true, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: true, + inventory_needed_to_start: true, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + defaults: { + extra_vars: '---', + diff_mode: false, + limit: '', + job_tags: '', + skip_tags: '', + job_type: 'run', + verbosity: 0, + inventory: { + name: null, + id: null, + }, + scm_branch: '', + }, + }, +}); + +SchedulesAPI.readCredentials.mockResolvedValue({ + data: { + results: [ + { name: 'schedule credential 1', id: 1, kind: 'vault' }, + { name: 'schedule credential 2', id: 2, kind: 'aws' }, + ], + count: 2, + }, +}); + +CredentialTypesAPI.loadAllTypes.mockResolvedValue([ + { id: 1, name: 'ssh', kind: 'ssh' }, +]); + +CredentialsAPI.read.mockResolvedValue({ + data: { + count: 3, + results: [ + { id: 1, name: 'Credential 1', kind: 'ssh', url: '' }, + { id: 2, name: 'Credential 2', kind: 'ssh', url: '' }, + { id: 3, name: 'Credential 3', kind: 'ssh', url: '' }, + ], + }, +}); + +CredentialsAPI.readOptions.mockResolvedValue({ + data: { related_search_fields: [], actions: { GET: { filterabled: true } } }, +}); + SchedulesAPI.update.mockResolvedValue({ data: { id: 27, @@ -37,13 +116,14 @@ const mockSchedule = { edit: true, delete: true, }, + inventory: { id: 702, name: 'Inventory' }, }, created: '2020-04-02T18:43:12.664142Z', modified: '2020-04-02T18:43:12.664185Z', name: 'mock schedule', description: '', extra_data: {}, - inventory: null, + inventory: 1, scm_branch: null, job_type: null, job_tags: null, @@ -61,18 +141,33 @@ const mockSchedule = { }; describe('', () => { - beforeAll(async () => { + beforeEach(async () => { await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts( + + ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); }); afterEach(() => { + wrapper.unmount(); jest.clearAllMocks(); }); test('Successfully creates a schedule with repeat frequency: None (run once)', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'none', @@ -85,13 +180,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run once schedule', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T100000 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', }); }); test('Successfully creates a schedule with 10 minute repeat frequency after 10 occurrences', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'after', frequency: 'minute', @@ -105,13 +201,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run every 10 minutes 10 times', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T103000 RRULE:INTERVAL=10;FREQ=MINUTELY;COUNT=10', }); }); test('Successfully creates a schedule with hourly repeat frequency ending on a specific date/time', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'onDate', endDateTime: '2020-03-26T10:45:00', @@ -125,13 +222,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run every hour until date', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=HOURLY;UNTIL=20200326T104500', }); }); test('Successfully creates a schedule with daily repeat frequency', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'day', @@ -144,13 +242,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run daily', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=DAILY', }); }); test('Successfully creates a schedule with weekly repeat frequency on mon/wed/fri', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ daysOfWeek: [RRule.MO, RRule.WE, RRule.FR], description: 'test description', end: 'never', @@ -165,12 +264,13 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run weekly on mon/wed/fri', + extra_data: {}, rrule: `DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=${RRule.MO},${RRule.WE},${RRule.FR}`, }); }); test('Successfully creates a schedule with monthly repeat frequency on the first day of the month', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'month', @@ -186,13 +286,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run on the first day of the month', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200401T104500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYMONTHDAY=1', }); }); test('Successfully creates a schedule with monthly repeat frequency on the last tuesday of the month', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', endDateTime: '2020-03-26T11:00:00', @@ -210,13 +311,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run monthly on the last Tuesday', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200331T110000 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=TU', }); }); test('Successfully creates a schedule with yearly repeat frequency on the first day of March', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -233,13 +335,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Yearly on the first day of March', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200301T000000 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=1', }); }); test('Successfully creates a schedule with yearly repeat frequency on the second Friday in April', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -257,13 +360,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Yearly on the second Friday in April', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=2;BYDAY=FR;BYMONTH=4', }); }); test('Successfully creates a schedule with yearly repeat frequency on the first weekday in October', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -281,8 +385,215 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Yearly on the first weekday in October', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=1;BYDAY=MO,TU,WE,TH,FR;BYMONTH=10', }); }); + + test('should open with correct values and navigate through the Promptable fields properly', async () => { + InventoriesAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { + name: 'Foo', + id: 1, + url: '', + }, + { + name: 'Bar', + id: 2, + url: '', + }, + ], + }, + }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { + related_search_fields: [], + actions: { + GET: { + filterable: true, + }, + }, + }, + }); + + await act(async () => + wrapper.find('Button[aria-label="Prompt"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('WizardNavItem').length).toBe(3); + expect( + wrapper + .find('WizardNavItem') + .at(0) + .prop('isCurrent') + ).toBe(true); + + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-1"]') + .simulate('change', { + target: { + checked: true, + }, + }); + }); + wrapper.update(); + + expect( + wrapper + .find('input[aria-labelledby="check-action-item-1"]') + .prop('checked') + ).toBe(true); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onNext')() + ); + wrapper.update(); + expect( + wrapper + .find('WizardNavItem') + .at(1) + .prop('isCurrent') + ).toBe(true); + + expect(wrapper.find('CredentialChip').length).toBe(3); + + wrapper.update(); + + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-3"]') + .simulate('change', { + target: { + checked: true, + }, + }); + }); + wrapper.update(); + expect( + wrapper + .find('input[aria-labelledby="check-action-item-3"]') + .prop('checked') + ).toBe(true); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onNext')() + ); + wrapper.update(); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onNext')() + ); + wrapper.update(); + expect(wrapper.find('Wizard').length).toBe(0); + await act(async () => { + wrapper.find('Formik').invoke('onSubmit')({ + name: mockSchedule.name, + end: 'never', + endDateTime: '2021-01-29T14:15:00', + frequency: 'none', + occurrences: 1, + runOn: 'day', + runOnDayMonth: 1, + runOnDayNumber: 1, + runOnTheDay: 'sunday', + runOnTheMonth: 1, + runOnTheOccurrence: 1, + skip_tags: '', + startDateTime: '2021-01-28T14:15:00', + timezone: 'America/New_York', + credentials: [ + { id: 3, name: 'Credential 3', kind: 'ssh', url: '' }, + { name: 'schedule credential 1', id: 1, kind: 'vault' }, + { name: 'schedule credential 2', id: 2, kind: 'aws' }, + ], + }); + }); + wrapper.update(); + + expect(SchedulesAPI.update).toBeCalledWith(27, { + extra_data: {}, + name: 'mock schedule', + rrule: + 'DTSTART;TZID=America/New_York:20210128T141500 RRULE:COUNT=1;FREQ=MINUTELY', + skip_tags: '', + }); + expect(SchedulesAPI.disassociateCredential).toBeCalledWith(27, 75); + + expect(SchedulesAPI.associateCredential).toBeCalledWith(27, 3); + }); + + test('should submit updated static form values, but original prompt form values', async () => { + InventoriesAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { + name: 'Foo', + id: 1, + url: '', + }, + { + name: 'Bar', + id: 2, + url: '', + }, + ], + }, + }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { + related_search_fields: [], + actions: { + GET: { + filterable: true, + }, + }, + }, + }); + await act(async () => + wrapper.find('input#schedule-name').simulate('change', { + target: { value: 'foo', name: 'name' }, + }) + ); + wrapper.update(); + await act(async () => + wrapper.find('Button[aria-label="Prompt"]').prop('onClick')() + ); + wrapper.update(); + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-2"]') + .simulate('change', { + target: { + checked: true, + }, + }); + }); + wrapper.update(); + + expect( + wrapper + .find('input[aria-labelledby="check-action-item-2"]') + .prop('checked') + ).toBe(true); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onClose')() + ); + wrapper.update(); + expect(wrapper.find('Wizard').length).toBe(0); + + await act(async () => + wrapper.find('Button[aria-label="Save"]').prop('onClick')() + ); + expect(SchedulesAPI.update).toBeCalledWith(27, { + description: '', + extra_data: {}, + inventory: 702, + name: 'foo', + rrule: + 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', + }); + }); }); diff --git a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx index 756e400258..35504df3b6 100644 --- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx @@ -1,22 +1,36 @@ -import React, { useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback, useState } from 'react'; import { shape, func } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Formik, useField } from 'formik'; import { RRule } from 'rrule'; -import { Form, FormGroup, Title } from '@patternfly/react-core'; +import { + Button, + Form, + FormGroup, + Title, + ActionGroup, +} from '@patternfly/react-core'; import { Config } from '../../../contexts/Config'; -import { SchedulesAPI } from '../../../api'; +import { + SchedulesAPI, + JobTemplatesAPI, + WorkflowJobTemplatesAPI, +} from '../../../api'; import AnsibleSelect from '../../AnsibleSelect'; import ContentError from '../../ContentError'; import ContentLoading from '../../ContentLoading'; -import FormActionGroup from '../../FormActionGroup/FormActionGroup'; import FormField, { FormSubmitError } from '../../FormField'; -import { FormColumnLayout, SubFormLayout } from '../../FormLayout'; +import { + FormColumnLayout, + SubFormLayout, + FormFullWidthLayout, +} from '../../FormLayout'; import { dateToInputDateTime, formatDateStringUTC } from '../../../util/dates'; import useRequest from '../../../util/useRequest'; import { required } from '../../../util/validators'; import FrequencyDetailSubform from './FrequencyDetailSubform'; +import SchedulePromptableFields from './SchedulePromptableFields'; const generateRunOnTheDay = (days = []) => { if ( @@ -179,8 +193,12 @@ function ScheduleForm({ i18n, schedule, submitError, + resource, ...rest }) { + const [isWizardOpen, setIsWizardOpen] = useState(false); + const [isSaveDisabled, setIsSaveDisabled] = useState(false); + let rruleError; const now = new Date(); const closestQuarterHour = new Date( @@ -189,6 +207,123 @@ function ScheduleForm({ const tomorrow = new Date(closestQuarterHour); tomorrow.setDate(tomorrow.getDate() + 1); + const isTemplate = + resource.type === 'workflow_job_template' || + resource.type === 'job_template'; + const isWorkflowJobTemplate = + isTemplate && resource.type === 'workflow_job_template'; + + const { + request: loadScheduleData, + error: contentError, + contentLoading, + result: { zoneOptions, surveyConfig, launchConfig, credentials }, + } = useRequest( + useCallback(async () => { + const readLaunch = + isTemplate && + (isWorkflowJobTemplate + ? WorkflowJobTemplatesAPI.readLaunch(resource.id) + : JobTemplatesAPI.readLaunch(resource.id)); + const [{ data }, { data: launchConfiguration }] = await Promise.all([ + SchedulesAPI.readZoneInfo(), + readLaunch, + ]); + + const readSurvey = isWorkflowJobTemplate + ? WorkflowJobTemplatesAPI.readSurvey(resource.id) + : JobTemplatesAPI.readSurvey(resource.id); + + let surveyConfiguration = null; + + if (isTemplate && launchConfiguration.survey_enabled) { + const { data: survey } = await readSurvey; + + surveyConfiguration = survey; + } + let creds; + if (schedule.id) { + const { + data: { results }, + } = await SchedulesAPI.readCredentials(schedule.id); + creds = results; + } + + const missingRequiredInventory = Boolean( + !resource.inventory && !schedule?.summary_fields?.inventory.id + ); + let missingRequiredSurvey = false; + + if ( + schedule.id && + isTemplate && + !launchConfiguration?.can_start_without_user_input + ) { + missingRequiredSurvey = surveyConfiguration?.spec?.every(question => { + let hasValue; + if (Object.keys(schedule)?.length === 0) { + hasValue = true; + } + Object.entries(schedule?.extra_data).forEach(([key, value]) => { + if ( + question.required && + question.variable === key && + value.length > 0 + ) { + hasValue = false; + } + }); + return hasValue; + }); + } + if (missingRequiredInventory || missingRequiredSurvey) { + setIsSaveDisabled(true); + } + + const zones = data.map(zone => { + return { + value: zone.name, + key: zone.name, + label: zone.name, + }; + }); + + return { + zoneOptions: zones, + surveyConfig: surveyConfiguration || {}, + launchConfig: launchConfiguration, + credentials: creds || [], + }; + }, [isTemplate, isWorkflowJobTemplate, resource, schedule]), + { + zonesOptions: [], + surveyConfig: {}, + launchConfig: {}, + credentials: [], + } + ); + + useEffect(() => { + loadScheduleData(); + }, [loadScheduleData]); + + let showPromptButton = false; + + if ( + launchConfig && + (launchConfig.ask_inventory_on_launch || + launchConfig.ask_variables_on_launch || + launchConfig.ask_job_type_on_launch || + launchConfig.ask_limit_on_launch || + launchConfig.ask_credential_on_launch || + launchConfig.ask_scm_branch_on_launch || + launchConfig.survey_enabled || + launchConfig.inventory_needed_to_start || + launchConfig.variables_needed_to_start?.length > 0) + ) { + showPromptButton = true; + } + const initialValues = { daysOfWeek: [], description: schedule.description || '', @@ -207,6 +342,19 @@ function ScheduleForm({ startDateTime: dateToInputDateTime(closestQuarterHour), timezone: schedule.timezone || 'America/New_York', }; + const submitSchedule = ( + values, + launchConfiguration, + surveyConfiguration, + scheduleCredentials + ) => { + handleSubmit( + values, + launchConfiguration, + surveyConfiguration, + scheduleCredentials + ); + }; const overriddenValues = {}; @@ -297,28 +445,6 @@ function ScheduleForm({ } } - const { - request: loadZoneInfo, - error: contentError, - contentLoading, - result: zoneOptions, - } = useRequest( - useCallback(async () => { - const { data } = await SchedulesAPI.readZoneInfo(); - return data.map(zone => { - return { - value: zone.name, - key: zone.name, - label: zone.name, - }; - }); - }, []) - ); - - useEffect(() => { - loadZoneInfo(); - }, [loadZoneInfo]); - if (contentError || rruleError) { return ; } @@ -333,7 +459,9 @@ function ScheduleForm({ return ( { + submitSchedule(values, launchConfig, surveyConfig, credentials); + }} validate={values => { const errors = {}; const { @@ -375,11 +503,55 @@ function ScheduleForm({ zoneOptions={zoneOptions} {...rest} /> + {isWizardOpen && ( + { + setIsWizardOpen(false); + setIsSaveDisabled(hasErrors); + }} + onSave={() => { + setIsWizardOpen(false); + setIsSaveDisabled(false); + }} + /> + )} - + + + + {isTemplate && showPromptButton && ( + + )} + + + )} diff --git a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx index 771a129b9e..c8fa312dc1 100644 --- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx @@ -1,11 +1,71 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import { SchedulesAPI } from '../../../api'; + +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import { SchedulesAPI, JobTemplatesAPI, InventoriesAPI } from '../../../api'; import ScheduleForm from './ScheduleForm'; jest.mock('../../../api/models/Schedules'); +jest.mock('../../../api/models/JobTemplates'); +jest.mock('../../../api/models/Inventories'); +const survey = { + name: '', + description: '', + spec: [ + { + question_name: 'new survey', + question_description: '', + required: true, + type: 'text', + variable: 'newsurveyquestion', + min: 0, + max: 1024, + default: '', + choices: '', + new_question: false, + }, + ], +}; +const credentials = { + data: { + results: [ + { id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' }, + { id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' }, + { id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' }, + { id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' }, + { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, + ], + }, +}; +const launchData = { + data: { + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: true, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: true, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + }, +}; const mockSchedule = { rrule: 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', @@ -23,7 +83,7 @@ const mockSchedule = { name: 'mock schedule', description: 'test description', extra_data: {}, - inventory: null, + inventory: 1, scm_branch: null, job_type: null, job_tags: null, @@ -82,7 +142,11 @@ describe('', () => { ); await act(async () => { wrapper = mountWithContexts( - + ); }); wrapper.update(); @@ -92,6 +156,10 @@ describe('', () => { describe('Cancel', () => { test('should make the appropriate callback', async () => { const handleCancel = jest.fn(); + JobTemplatesAPI.readLaunch.mockResolvedValue(launchData); + + JobTemplatesAPI.readSurvey.mockResolvedValue(survey); + SchedulesAPI.readCredentials.mockResolvedValue(credentials); SchedulesAPI.readZoneInfo.mockResolvedValue({ data: [ { @@ -101,7 +169,11 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - + ); }); wrapper.update(); @@ -111,6 +183,173 @@ describe('', () => { expect(handleCancel).toHaveBeenCalledTimes(1); }); }); + describe('Prompted Schedule', () => { + let promptWrapper; + beforeEach(async () => { + JobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: true, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: true, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + }, + }); + SchedulesAPI.readZoneInfo.mockResolvedValue({ + data: [ + { + name: 'America/New_York', + }, + ], + }); + await act(async () => { + promptWrapper = mountWithContexts( + + ); + }); + waitForElement( + promptWrapper, + 'Button[aria-label="Prompt"]', + el => el.length > 0 + ); + }); + afterEach(() => { + promptWrapper.unmount(); + jest.clearAllMocks(); + }); + + test('should open prompt modal with proper steps and default values', async () => { + await act(async () => + promptWrapper.find('Button[aria-label="Prompt"]').prop('onClick')() + ); + promptWrapper.update(); + waitForElement(promptWrapper, 'Wizard', el => el.length > 0); + expect(promptWrapper.find('Wizard').length).toBe(1); + expect(promptWrapper.find('StepName#inventory-step').length).toBe(2); + expect(promptWrapper.find('StepName#preview-step').length).toBe(1); + expect(promptWrapper.find('WizardNavItem').length).toBe(2); + }); + + test('should update prompt modal data', async () => { + InventoriesAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { + name: 'Foo', + id: 1, + url: '', + }, + { + name: 'Bar', + id: 2, + url: '', + }, + ], + }, + }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { + related_search_fields: [], + actions: { + GET: { + filterable: true, + }, + }, + }, + }); + + await act(async () => + promptWrapper.find('Button[aria-label="Prompt"]').prop('onClick')() + ); + promptWrapper.update(); + expect( + promptWrapper + .find('WizardNavItem') + .at(0) + .prop('isCurrent') + ).toBe(true); + await act(async () => { + promptWrapper + .find('input[aria-labelledby="check-action-item-1"]') + .simulate('change', { + target: { + checked: true, + }, + }); + }); + promptWrapper.update(); + expect( + promptWrapper + .find('input[aria-labelledby="check-action-item-1"]') + .prop('checked') + ).toBe(true); + await act(async () => + promptWrapper.find('WizardFooterInternal').prop('onNext')() + ); + promptWrapper.update(); + expect( + promptWrapper + .find('WizardNavItem') + .at(1) + .prop('isCurrent') + ).toBe(true); + await act(async () => + promptWrapper.find('WizardFooterInternal').prop('onNext')() + ); + promptWrapper.update(); + expect(promptWrapper.find('Wizard').length).toBe(0); + }); + test('should render prompt button with disabled save button', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + waitForElement( + wrapper, + 'Button[aria-label="Prompt"]', + el => el.length > 0 + ); + expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe( + true + ); + }); + }); describe('Add', () => { beforeAll(async () => { SchedulesAPI.readZoneInfo.mockResolvedValue({ @@ -120,9 +359,39 @@ describe('', () => { }, ], }); + JobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: true, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: false, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: false, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + }, + }); + await act(async () => { wrapper = mountWithContexts( - + ); }); }); @@ -312,6 +581,14 @@ describe('', () => { expect(wrapper.find('select#schedule-run-on-the-month').length).toBe(1); }); test('occurrences field properly shown when end after selection is made', async () => { + await act(async () => { + wrapper + .find('FormGroup[label="Run frequency"] FormSelect') + .invoke('onChange')('minute', { + target: { value: 'minute', key: 'minute', label: 'Minute' }, + }); + }); + wrapper.update(); await act(async () => { wrapper.find('Radio#end-after').invoke('onChange')('after', { target: { name: 'end' }, @@ -331,6 +608,14 @@ describe('', () => { wrapper.update(); }); test('error shown when end date/time comes before start date/time', async () => { + await act(async () => { + wrapper + .find('FormGroup[label="Run frequency"] FormSelect') + .invoke('onChange')('minute', { + target: { value: 'minute', key: 'minute', label: 'Minute' }, + }); + }); + wrapper.update(); expect(wrapper.find('input#end-never').prop('checked')).toBe(true); expect(wrapper.find('input#end-after').prop('checked')).toBe(false); expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); @@ -361,13 +646,28 @@ describe('', () => { ); }); test('error shown when on day number is not between 1 and 31', async () => { - await act(async () => { + act(() => { + wrapper.find('select[id="schedule-frequency"]').invoke('onChange')( + { + currentTarget: { value: 'month', type: 'change' }, + target: { name: 'frequency', value: 'month' }, + }, + 'month' + ); + }); + wrapper.update(); + + act(() => { wrapper.find('input#schedule-run-on-day-number').simulate('change', { target: { value: 32, name: 'runOnDayNumber' }, }); }); wrapper.update(); + expect( + wrapper.find('input#schedule-run-on-day-number').prop('value') + ).toBe(32); + await act(async () => { wrapper.find('button[aria-label="Save"]').simulate('click'); }); @@ -379,7 +679,7 @@ describe('', () => { }); }); describe('Edit', () => { - beforeAll(async () => { + beforeEach(async () => { SchedulesAPI.readZoneInfo.mockResolvedValue({ data: [ { @@ -387,10 +687,94 @@ describe('', () => { }, ], }); + JobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: true, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: false, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: false, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + }, + }); + JobTemplatesAPI.readSurvey.mockResolvedValue({}); + SchedulesAPI.readCredentials.mockResolvedValue(credentials); }); afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + test('should make API calls to fetch credentials, launch configuration, and survey configuration', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(JobTemplatesAPI.readLaunch).toBeCalledWith(23); + expect(JobTemplatesAPI.readSurvey).toBeCalledWith(23); + expect(SchedulesAPI.readCredentials).toBeCalledWith(27); + }); + + test('should not call API to get credentials ', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + + expect(SchedulesAPI.readCredentials).not.toBeCalled(); + }); + + test('should render prompt button with enabled save button for project', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + waitForElement( + wrapper, + 'Button[aria-label="Prompt"]', + el => el.length > 0 + ); + + expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe( + false + ); + }); + test('initially renders expected fields and values with existing schedule that runs once', async () => { await act(async () => { wrapper = mountWithContexts( @@ -398,6 +782,7 @@ describe('', () => { handleSubmit={jest.fn()} handleCancel={jest.fn()} schedule={mockSchedule} + resource={{ id: 23, type: 'job_template' }} /> ); }); @@ -426,6 +811,7 @@ describe('', () => { 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=10;FREQ=MINUTELY', dtend: null, })} + resource={{ id: 23, type: 'job_template' }} /> ); }); @@ -459,6 +845,7 @@ describe('', () => { dtend: '2020-04-03T03:45:00Z', until: '', })} + resource={{ id: 23, type: 'job_template' }} /> ); }); @@ -493,6 +880,7 @@ describe('', () => { dtend: null, until: '', })} + resource={{ id: 23, type: 'job_template' }} /> ); expect(wrapper.find('ScheduleForm').length).toBe(1); @@ -526,6 +914,7 @@ describe('', () => { dtend: '2020-10-30T18:45:00Z', until: '2021-01-01T00:00:00', })} + resource={{ id: 23, type: 'job_template' }} /> ); }); @@ -583,6 +972,7 @@ describe('', () => { dtend: null, until: '', })} + resource={{ id: 23, type: 'job_template' }} /> ); expect(wrapper.find('ScheduleForm').length).toBe(1); @@ -628,6 +1018,7 @@ describe('', () => { dtend: null, until: '', })} + resource={{ id: 23, type: 'job_template' }} /> ); expect(wrapper.find('ScheduleForm').length).toBe(1); diff --git a/awx/ui_next/src/components/Schedule/shared/SchedulePromptableFields.jsx b/awx/ui_next/src/components/Schedule/shared/SchedulePromptableFields.jsx new file mode 100644 index 0000000000..0b0e8d2f3c --- /dev/null +++ b/awx/ui_next/src/components/Schedule/shared/SchedulePromptableFields.jsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { Wizard } from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useFormikContext } from 'formik'; +import AlertModal from '../../AlertModal'; +import { useDismissableError } from '../../../util/useRequest'; +import ContentError from '../../ContentError'; +import ContentLoading from '../../ContentLoading'; +import useSchedulePromptSteps from './useSchedulePromptSteps'; + +function SchedulePromptableFields({ + schedule, + surveyConfig, + launchConfig, + onCloseWizard, + onSave, + credentials, + resource, + i18n, +}) { + const { + validateForm, + setFieldTouched, + values, + initialValues, + resetForm, + } = useFormikContext(); + const { + steps, + visitStep, + visitAllSteps, + validateStep, + contentError, + isReady, + } = useSchedulePromptSteps( + surveyConfig, + launchConfig, + schedule, + resource, + i18n, + credentials + ); + + const { error, dismissError } = useDismissableError(contentError); + const cancelPromptableValues = async () => { + const hasErrors = await validateForm(); + resetForm({ + values: { + ...initialValues, + daysOfWeek: values.daysOfWeek, + description: values.description, + end: values.end, + endDateTime: values.endDateTime, + frequency: values.frequency, + interval: values.interval, + name: values.name, + occurences: values.occurances, + runOn: values.runOn, + runOnDayMonth: values.runOnDayMonth, + runOnDayNumber: values.runOnDayNumber, + runOnTheDay: values.runOnTheDay, + runOnTheMonth: values.runOnTheMonth, + runOnTheOccurence: values.runOnTheOccurance, + startDateTime: values.startDateTime, + timezone: values.timezone, + }, + }); + onCloseWizard(Object.keys(hasErrors).length > 0); + }; + + if (error) { + return ( + { + dismissError(); + onCloseWizard(); + }} + > + + + ); + } + return ( + { + if (nextStep.id === 'preview') { + visitAllSteps(setFieldTouched); + } else { + visitStep(prevStep.prevId, setFieldTouched); + } + await validateForm(); + }} + onGoToStep={async (nextStep, prevStep) => { + if (nextStep.id === 'preview') { + visitAllSteps(setFieldTouched); + } else { + visitStep(prevStep.prevId, setFieldTouched); + validateStep(nextStep.id); + } + await validateForm(); + }} + title={i18n._(t`Prompts`)} + steps={ + isReady + ? steps + : [ + { + name: i18n._(t`Content Loading`), + component: , + }, + ] + } + backButtonText={i18n._(t`Back`)} + cancelButtonText={i18n._(t`Cancel`)} + nextButtonText={i18n._(t`Next`)} + /> + ); +} + +export default withI18n()(SchedulePromptableFields); diff --git a/awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js b/awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js new file mode 100644 index 0000000000..94d3dd5fb4 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js @@ -0,0 +1,102 @@ +import { useState, useEffect } from 'react'; +import { useFormikContext } from 'formik'; +import { t } from '@lingui/macro'; +import useInventoryStep from '../../LaunchPrompt/steps/useInventoryStep'; +import useCredentialsStep from '../../LaunchPrompt/steps/useCredentialsStep'; +import useOtherPromptsStep from '../../LaunchPrompt/steps/useOtherPromptsStep'; +import useSurveyStep from '../../LaunchPrompt/steps/useSurveyStep'; +import usePreviewStep from '../../LaunchPrompt/steps/usePreviewStep'; + +export default function useSchedulePromptSteps( + surveyConfig, + launchConfig, + schedule, + resource, + i18n, + scheduleCredentials +) { + const { + summary_fields: { credentials: resourceCredentials }, + } = resource; + const sourceOfValues = + (Object.keys(schedule).length > 0 && schedule) || resource; + + sourceOfValues.summary_fields = { + credentials: [...resourceCredentials, ...scheduleCredentials], + ...sourceOfValues.summary_fields, + }; + const { resetForm, values } = useFormikContext(); + const [visited, setVisited] = useState({}); + + const steps = [ + useInventoryStep(launchConfig, sourceOfValues, i18n, visited), + useCredentialsStep(launchConfig, sourceOfValues, i18n), + useOtherPromptsStep(launchConfig, sourceOfValues, i18n), + useSurveyStep(launchConfig, surveyConfig, sourceOfValues, i18n, visited), + ]; + + const hasErrors = steps.some(step => step.hasError); + steps.push( + usePreviewStep( + launchConfig, + i18n, + resource, + surveyConfig, + hasErrors, + true, + i18n._(t`Save`) + ) + ); + + const pfSteps = steps.map(s => s.step).filter(s => s != null); + const isReady = !steps.some(s => !s.isReady); + + useEffect(() => { + let initialValues = {}; + if (launchConfig && surveyConfig && isReady) { + initialValues = steps.reduce((acc, cur) => { + return { + ...acc, + ...cur.initialValues, + }; + }, {}); + } + resetForm({ + values: { + ...initialValues, + ...values, + }, + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [launchConfig, surveyConfig, isReady]); + + const stepWithError = steps.find(s => s.contentError); + const contentError = stepWithError ? stepWithError.contentError : null; + + return { + isReady, + validateStep: stepId => { + steps.find(s => s?.step?.id === stepId).validate(); + }, + steps: pfSteps, + visitStep: (prevStepId, setFieldTouched) => { + setVisited({ + ...visited, + [prevStepId]: true, + }); + steps.find(s => s?.step?.id === prevStepId).setTouched(setFieldTouched); + }, + visitAllSteps: setFieldTouched => { + setVisited({ + inventory: true, + credentials: true, + other: true, + survey: true, + preview: true, + }); + steps.forEach(s => s.setTouched(setFieldTouched)); + }, + contentError, + }; +} diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx index b50b25ea23..04063ed310 100644 --- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx +++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx @@ -326,6 +326,11 @@ function JobDetail({ job, i18n }) { user={created_by} /> + {job.extra_vars && ( ', () => { name: 'Test Source Workflow', }, }, + job_explanation: 'It failed, bummer!', }} /> ); @@ -69,6 +70,7 @@ describe('', () => { assertDetail('Job Slice', '0/1'); assertDetail('Credentials', 'SSH: Demo Credential'); assertDetail('Machine Credential', 'SSH: Machine cred'); + assertDetail('Explanation', 'It failed, bummer!'); const credentialChip = wrapper.find( `Detail[label="Credentials"] CredentialChip` From 561390d405b8028c913a6536c68eda8ec0337802 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Mon, 15 Feb 2021 13:44:15 -0500 Subject: [PATCH 3/4] Refactors to add warning icon and disable save if schedule has missing values --- .../src/components/Schedule/Schedule.jsx | 17 +- .../Schedule/ScheduleAdd/ScheduleAdd.jsx | 15 +- .../Schedule/ScheduleAdd/ScheduleAdd.test.jsx | 78 ++--- .../ScheduleDetail/ScheduleDetail.jsx | 58 +++- .../ScheduleDetail/ScheduleDetail.test.jsx | 36 +++ .../Schedule/ScheduleEdit/ScheduleEdit.jsx | 32 +- .../ScheduleEdit/ScheduleEdit.test.jsx | 95 +++--- .../Schedule/ScheduleList/ScheduleList.jsx | 46 +++ .../ScheduleList/ScheduleList.test.jsx | 61 +++- .../ScheduleList/ScheduleListItem.jsx | 38 ++- .../ScheduleList/ScheduleListItem.test.jsx | 50 +++- .../ScheduleToggle/ScheduleToggle.jsx | 6 +- .../src/components/Schedule/Schedules.jsx | 19 +- .../components/Schedule/data.schedules.json | 7 +- .../Schedule/shared/ScheduleForm.jsx | 119 ++++---- .../Schedule/shared/ScheduleForm.test.jsx | 277 +++++++++++------- .../Schedule/shared/useSchedulePromptSteps.js | 2 +- .../src/screens/Job/JobDetail/JobDetail.jsx | 5 - .../screens/Job/JobDetail/JobDetail.test.jsx | 2 - awx/ui_next/src/screens/Template/Template.jsx | 21 +- .../src/screens/Template/Template.test.jsx | 3 +- .../screens/Template/WorkflowJobTemplate.jsx | 24 +- .../Template/WorkflowJobTemplate.test.jsx | 3 +- 23 files changed, 704 insertions(+), 310 deletions(-) diff --git a/awx/ui_next/src/components/Schedule/Schedule.jsx b/awx/ui_next/src/components/Schedule/Schedule.jsx index 7974ba072b..e1e59c5d85 100644 --- a/awx/ui_next/src/components/Schedule/Schedule.jsx +++ b/awx/ui_next/src/components/Schedule/Schedule.jsx @@ -19,7 +19,13 @@ import ScheduleEdit from './ScheduleEdit'; import { SchedulesAPI } from '../../api'; import useRequest from '../../util/useRequest'; -function Schedule({ i18n, setBreadcrumb, resource }) { +function Schedule({ + i18n, + setBreadcrumb, + resource, + launchConfig, + surveyConfig, +}) { const { scheduleId } = useParams(); const { pathname } = useLocation(); @@ -100,13 +106,18 @@ function Schedule({ i18n, setBreadcrumb, resource }) { /> {schedule && [ - + , - + , ]} diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx index 1fd59a91f7..81f42f5b72 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx @@ -15,14 +15,18 @@ import mergeExtraVars from '../../../util/prompt/mergeExtraVars'; import getSurveyValues from '../../../util/prompt/getSurveyValues'; import { getAddedAndRemoved } from '../../../util/lists'; -function ScheduleAdd({ i18n, resource, apiModel }) { +function ScheduleAdd({ i18n, resource, apiModel, launchConfig, surveyConfig }) { const [formSubmitError, setFormSubmitError] = useState(null); const history = useHistory(); const location = useLocation(); const { pathname } = location; const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); - const handleSubmit = async (values, launchConfig, surveyConfig) => { + const handleSubmit = async ( + values, + launchConfiguration, + surveyConfiguration + ) => { const { inventory, extra_vars, @@ -51,8 +55,9 @@ function ScheduleAdd({ i18n, resource, apiModel }) { let extraVars; const surveyValues = getSurveyValues(values); const initialExtraVars = - launchConfig?.ask_variables_on_launch && (values.extra_vars || '---'); - if (surveyConfig?.spec) { + launchConfiguration?.ask_variables_on_launch && + (values.extra_vars || '---'); + if (surveyConfiguration?.spec) { extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, surveyValues)); } else { extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, {})); @@ -92,6 +97,8 @@ function ScheduleAdd({ i18n, resource, apiModel }) { handleCancel={() => history.push(`${pathRoot}schedules`)} handleSubmit={handleSubmit} submitError={formSubmitError} + launchConfig={launchConfig} + surveyConfig={surveyConfig} resource={resource} /> diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx index 41f6b02d1d..970bb91476 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx @@ -19,45 +19,45 @@ SchedulesAPI.readZoneInfo.mockResolvedValue({ }, ], }); -JobTemplatesAPI.readLaunch.mockResolvedValue({ - data: { - can_start_without_user_input: false, - passwords_needed_to_start: [], - ask_scm_branch_on_launch: false, - ask_variables_on_launch: false, - ask_tags_on_launch: false, - ask_diff_mode_on_launch: false, - ask_skip_tags_on_launch: false, - ask_job_type_on_launch: false, - ask_limit_on_launch: false, - ask_verbosity_on_launch: false, - ask_inventory_on_launch: true, - ask_credential_on_launch: false, - survey_enabled: false, - variables_needed_to_start: [], - credential_needed_to_start: false, - inventory_needed_to_start: true, - job_template_data: { - name: 'Demo Job Template', - id: 7, - description: '', - }, - defaults: { - extra_vars: '---', - diff_mode: false, - limit: '', - job_tags: '', - skip_tags: '', - job_type: 'run', - verbosity: 0, - inventory: { - name: null, - id: null, - }, - scm_branch: '', - }, + +const launchConfig = { + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: true, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: true, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', }, -}); + defaults: { + extra_vars: '---', + diff_mode: false, + limit: '', + job_tags: '', + skip_tags: '', + job_type: 'run', + verbosity: 0, + inventory: { + name: null, + id: null, + }, + scm_branch: '', + }, +}; + JobTemplatesAPI.createSchedule.mockResolvedValue({ data: { id: 3 } }); let wrapper; @@ -74,6 +74,7 @@ describe('', () => { inventory: 2, summary_fields: { credentials: [] }, }} + launchConfig={launchConfig} /> ); }); @@ -377,7 +378,6 @@ describe('', () => { ); wrapper.update(); expect(wrapper.find('Wizard').length).toBe(0); - // console.log(wrapper.debug()); await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ name: 'Schedule', diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx index 19ea0495c5..c9e4f17fa4 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx @@ -42,7 +42,7 @@ const PromptDetailList = styled(DetailList)` padding: 0px 20px; `; -function ScheduleDetail({ schedule, i18n }) { +function ScheduleDetail({ schedule, i18n, surveyConfig }) { const { id, created, @@ -148,6 +148,7 @@ function ScheduleDetail({ schedule, i18n }) { const { ask_credential_on_launch, + inventory_needed_to_start, ask_diff_mode_on_launch, ask_inventory_on_launch, ask_job_type_on_launch, @@ -160,6 +161,41 @@ function ScheduleDetail({ schedule, i18n }) { survey_enabled, } = launchData || {}; + const missingRequiredInventory = () => { + if (!inventory_needed_to_start || schedule?.summary_fields?.inventory?.id) { + return false; + } + return true; + }; + + const hasMissingSurveyValue = () => { + let missingValues = false; + if (survey_enabled) { + surveyConfig.spec.forEach(question => { + const hasDefaultValue = Boolean(question.default); + if (question.required && !hasDefaultValue) { + const extraDataKeys = Object.keys(schedule?.extra_data); + + const hasMatchingKey = extraDataKeys.includes(question.variable); + Object.values(schedule?.extra_data).forEach(value => { + if (!value || !hasMatchingKey) { + missingValues = true; + } else { + missingValues = false; + } + }); + if (!Object.values(schedule.extra_data).length) { + missingValues = true; + } + } + }); + } + return missingValues; + }; + const isDisabled = Boolean( + missingRequiredInventory() || hasMissingSurveyValue() + ); + const showCredentialsDetail = ask_credential_on_launch && credentials.length > 0; const showInventoryDetail = ask_inventory_on_launch && inventory; @@ -189,14 +225,6 @@ function ScheduleDetail({ schedule, i18n }) { showVerbosityDetail || showVariablesDetail; - const VERBOSITY = { - 0: i18n._(t`0 (Normal)`), - 1: i18n._(t`1 (Verbose)`), - 2: i18n._(t`2 (More Verbose)`), - 3: i18n._(t`3 (Debug)`), - 4: i18n._(t`4 (Connection Debug)`), - }; - if (isLoading) { return ; } @@ -207,7 +235,11 @@ function ScheduleDetail({ schedule, i18n }) { return ( - + @@ -279,12 +311,6 @@ function ScheduleDetail({ schedule, i18n }) { {ask_limit_on_launch && ( )} - {ask_verbosity_on_launch && ( - - )} {showDiffModeDetail && ( ', () => { ); expect(SchedulesAPI.destroy).toHaveBeenCalledTimes(1); }); + test('should have disabled toggle', async () => { + SchedulesAPI.readCredentials.mockResolvedValueOnce({ + data: { + count: 0, + results: [], + }, + }); + JobTemplatesAPI.readLaunch.mockResolvedValueOnce(allPrompts); + await act(async () => { + wrapper = mountWithContexts( + ( + + )} + />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { params: { id: 1 } }, + }, + }, + }, + } + ); + }); + await waitForElement( + wrapper, + 'ScheduleToggle', + el => el.prop('isDisabled') === true + ); + }); }); diff --git a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx index e5faf4dbeb..9f11e8676b 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx @@ -15,7 +15,13 @@ import { parseVariableField } from '../../../util/yaml'; import mergeExtraVars from '../../../util/prompt/mergeExtraVars'; import getSurveyValues from '../../../util/prompt/getSurveyValues'; -function ScheduleEdit({ i18n, schedule, resource }) { +function ScheduleEdit({ + i18n, + schedule, + resource, + launchConfig, + surveyConfig, +}) { const [formSubmitError, setFormSubmitError] = useState(null); const history = useHistory(); const location = useLocation(); @@ -24,8 +30,8 @@ function ScheduleEdit({ i18n, schedule, resource }) { const handleSubmit = async ( values, - launchConfig, - surveyConfig, + launchConfiguration, + surveyConfiguration, scheduleCredentials = [] ) => { const { @@ -36,32 +42,40 @@ function ScheduleEdit({ i18n, schedule, resource }) { interval, startDateTime, timezone, - occurrences, + occurences, runOn, runOnTheDay, runOnTheMonth, runOnDayMonth, runOnDayNumber, endDateTime, - runOnTheOccurrence, + runOnTheOccurence, daysOfWeek, ...submitValues } = values; const { added, removed } = getAddedAndRemoved( - [...resource?.summary_fields.credentials, ...scheduleCredentials], + [...(resource?.summary_fields.credentials || []), ...scheduleCredentials], credentials ); let extraVars; const surveyValues = getSurveyValues(values); const initialExtraVars = - launchConfig?.ask_variables_on_launch && (values.extra_vars || '---'); - if (surveyConfig?.spec) { + launchConfiguration?.ask_variables_on_launch && + (values.extra_vars || '---'); + if (surveyConfiguration?.spec) { extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, surveyValues)); } else { extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, {})); } submitValues.extra_data = extraVars && parseVariableField(extraVars); + + if ( + Object.keys(submitValues.extra_data).length === 0 && + Object.keys(schedule.extra_data).length > 0 + ) { + submitValues.extra_data = schedule.extra_data; + } delete values.extra_vars; if (inventory) { submitValues.inventory = inventory.id; @@ -103,6 +117,8 @@ function ScheduleEdit({ i18n, schedule, resource }) { handleSubmit={handleSubmit} submitError={formSubmitError} resource={resource} + launchConfig={launchConfig} + surveyConfig={surveyConfig} /> diff --git a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx index 404f7334cb..c70eccad6a 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx @@ -7,7 +7,6 @@ import { } from '../../../../testUtils/enzymeHelpers'; import { SchedulesAPI, - JobTemplatesAPI, InventoriesAPI, CredentialsAPI, CredentialTypesAPI, @@ -28,46 +27,6 @@ SchedulesAPI.readZoneInfo.mockResolvedValue({ ], }); -JobTemplatesAPI.readLaunch.mockResolvedValue({ - data: { - can_start_without_user_input: false, - passwords_needed_to_start: [], - ask_scm_branch_on_launch: false, - ask_variables_on_launch: false, - ask_tags_on_launch: false, - ask_diff_mode_on_launch: false, - ask_skip_tags_on_launch: false, - ask_job_type_on_launch: false, - ask_limit_on_launch: false, - ask_verbosity_on_launch: false, - ask_inventory_on_launch: true, - ask_credential_on_launch: true, - survey_enabled: false, - variables_needed_to_start: [], - credential_needed_to_start: true, - inventory_needed_to_start: true, - job_template_data: { - name: 'Demo Job Template', - id: 7, - description: '', - }, - defaults: { - extra_vars: '---', - diff_mode: false, - limit: '', - job_tags: '', - skip_tags: '', - job_type: 'run', - verbosity: 0, - inventory: { - name: null, - id: null, - }, - scm_branch: '', - }, - }, -}); - SchedulesAPI.readCredentials.mockResolvedValue({ data: { results: [ @@ -156,6 +115,44 @@ describe('', () => { ], }, }} + launchConfig={{ + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: true, + ask_credential_on_launch: true, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: true, + inventory_needed_to_start: true, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + defaults: { + extra_vars: '---', + diff_mode: false, + limit: '', + job_tags: '', + skip_tags: '', + job_type: 'run', + verbosity: 0, + inventory: { + name: null, + id: null, + }, + scm_branch: '', + }, + }} + surveyConfig={{}} /> ); }); @@ -202,6 +199,7 @@ describe('', () => { description: 'test description', name: 'Run every 10 minutes 10 times', extra_data: {}, + occurrences: 10, rrule: 'DTSTART;TZID=America/New_York:20200325T103000 RRULE:INTERVAL=10;FREQ=MINUTELY;COUNT=10', }); @@ -265,6 +263,7 @@ describe('', () => { description: 'test description', name: 'Run weekly on mon/wed/fri', extra_data: {}, + occurrences: 1, rrule: `DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=${RRule.MO},${RRule.WE},${RRule.FR}`, }); }); @@ -287,6 +286,7 @@ describe('', () => { description: 'test description', name: 'Run on the first day of the month', extra_data: {}, + occurrences: 1, rrule: 'DTSTART;TZID=America/New_York:20200401T104500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYMONTHDAY=1', }); @@ -312,6 +312,8 @@ describe('', () => { description: 'test description', name: 'Run monthly on the last Tuesday', extra_data: {}, + occurrences: 1, + runOnTheOccurrence: -1, rrule: 'DTSTART;TZID=America/New_York:20200331T110000 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=TU', }); @@ -336,6 +338,7 @@ describe('', () => { description: 'test description', name: 'Yearly on the first day of March', extra_data: {}, + occurrences: 1, rrule: 'DTSTART;TZID=America/New_York:20200301T000000 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=1', }); @@ -361,6 +364,8 @@ describe('', () => { description: 'test description', name: 'Yearly on the second Friday in April', extra_data: {}, + occurrences: 1, + runOnTheOccurrence: 2, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=2;BYDAY=FR;BYMONTH=4', }); @@ -386,6 +391,8 @@ describe('', () => { description: 'test description', name: 'Yearly on the first weekday in October', extra_data: {}, + occurrences: 1, + runOnTheOccurrence: 1, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=1;BYDAY=MO,TU,WE,TH,FR;BYMONTH=10', }); @@ -515,6 +522,8 @@ describe('', () => { expect(SchedulesAPI.update).toBeCalledWith(27, { extra_data: {}, name: 'mock schedule', + occurrences: 1, + runOnTheOccurrence: 1, rrule: 'DTSTART;TZID=America/New_York:20210128T141500 RRULE:COUNT=1;FREQ=MINUTELY', skip_tags: '', @@ -590,8 +599,10 @@ describe('', () => { expect(SchedulesAPI.update).toBeCalledWith(27, { description: '', extra_data: {}, - inventory: 702, + occurrences: 1, + runOnTheOccurrence: 1, name: 'foo', + inventory: 702, rrule: 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', }); diff --git a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx index 9bdef2c437..7e5f7d9805 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx @@ -24,6 +24,9 @@ function ScheduleList({ loadSchedules, loadScheduleOptions, hideAddButton, + resource, + launchConfig, + surveyConfig, }) { const [selected, setSelected] = useState([]); @@ -114,6 +117,47 @@ function ScheduleList({ actions && Object.prototype.hasOwnProperty.call(actions, 'POST') && !hideAddButton; + const isTemplate = + resource?.type === 'workflow_job_template' || + resource?.type === 'job_template'; + + const missingRequiredInventory = schedule => { + if ( + !launchConfig.inventory_needed_to_start || + schedule?.summary_fields?.inventory?.id + ) { + return null; + } + return i18n._(t`This schedule is missing an Inventory`); + }; + + const hasMissingSurveyValue = schedule => { + let missingValues; + if (launchConfig.survey_enabled) { + surveyConfig.spec.forEach(question => { + const hasDefaultValue = Boolean(question.default); + if (question.required && !hasDefaultValue) { + const extraDataKeys = Object.keys(schedule?.extra_data); + + const hasMatchingKey = extraDataKeys.includes(question.variable); + Object.values(schedule?.extra_data).forEach(value => { + if (!value || !hasMatchingKey) { + missingValues = true; + } else { + missingValues = false; + } + }); + if (!Object.values(schedule.extra_data).length) { + missingValues = true; + } + } + }); + } + return ( + missingValues && + i18n._(t`This schedule is missing required survey values`) + ); + }; return ( <> @@ -139,6 +183,8 @@ function ScheduleList({ onSelect={() => handleSelect(item)} schedule={item} rowIndex={index} + isMissingInventory={isTemplate && missingRequiredInventory(item)} + isMissingSurvey={isTemplate && hasMissingSurveyValue(item)} /> )} toolbarSearchColumns={[ diff --git a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.test.jsx index 963a51cfcf..2da9d89d6c 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.test.jsx @@ -32,19 +32,22 @@ describe('ScheduleList', () => { }); describe('read call successful', () => { - beforeAll(async () => { + beforeEach(async () => { await act(async () => { wrapper = mountWithContexts( ); }); wrapper.update(); }); - afterAll(() => { + afterEach(() => { wrapper.unmount(); }); @@ -203,6 +206,60 @@ describe('ScheduleList', () => { wrapper.update(); expect(wrapper.find('ToolbarAddButton').length).toBe(0); }); + test('should show missing resource icon and disabled toggle', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + expect( + wrapper + .find('ScheduleListItem') + .at(4) + .prop('isMissingSurvey') + ).toBe('This schedule is missing required survey values'); + expect(wrapper.find('ExclamationTriangleIcon').length).toBe(5); + expect(wrapper.find('Switch#schedule-5-toggle').prop('isDisabled')).toBe( + true + ); + }); + test('should show missing resource icon and disabled toggle', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + + expect( + wrapper + .find('ScheduleListItem') + .at(3) + .prop('isMissingInventory') + ).toBe('This schedule is missing an Inventory'); + expect(wrapper.find('ExclamationTriangleIcon').length).toBe(4); + expect(wrapper.find('Switch#schedule-3-toggle').prop('isDisabled')).toBe( + true + ); + }); }); describe('read call unsuccessful', () => { diff --git a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx index 6e71a64e18..b642f28638 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.jsx @@ -4,16 +4,33 @@ import { bool, func } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Link } from 'react-router-dom'; -import { Button } from '@patternfly/react-core'; +import { Button, Tooltip } from '@patternfly/react-core'; import { Tr, Td } from '@patternfly/react-table'; -import { PencilAltIcon } from '@patternfly/react-icons'; +import { + PencilAltIcon, + ExclamationTriangleIcon as PFExclamationTriangleIcon, +} from '@patternfly/react-icons'; +import styled from 'styled-components'; import { DetailList, Detail } from '../../DetailList'; import { ActionsTd, ActionItem } from '../../PaginatedTable'; import { ScheduleToggle } from '..'; import { Schedule } from '../../../types'; import { formatDateString } from '../../../util/dates'; -function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) { +const ExclamationTriangleIcon = styled(PFExclamationTriangleIcon)` + color: #c9190b; + margin-left: 20px; +`; + +function ScheduleListItem({ + i18n, + rowIndex, + isSelected, + onSelect, + schedule, + isMissingInventory, + isMissingSurvey, +}) { const labelId = `check-action-${schedule.id}`; const jobTypeLabels = { @@ -45,6 +62,7 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) { default: break; } + const isDisabled = Boolean(isMissingInventory || isMissingSurvey); return ( @@ -61,6 +79,18 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) { {schedule.name} + {Boolean(isMissingInventory || isMissingSurvey) && ( + + ( +
{message}
+ ))} + position="right" + > + +
+
+ )} { @@ -80,7 +110,7 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) { )} - + { describe('User has edit permissions', () => { beforeAll(() => { wrapper = mountWithContexts( - - - - -
+ ); }); @@ -118,6 +116,9 @@ describe('ScheduleListItem', () => { .simulate('change'); expect(onSelect).toHaveBeenCalledTimes(1); }); + test('Toggle button is enabled', () => { + expect(wrapper.find('ScheduleToggle').prop('isDisabled')).toBe(false); + }); }); describe('User has read-only permissions', () => { @@ -186,4 +187,35 @@ describe('ScheduleListItem', () => { ).toBe(true); }); }); + describe('schedule has missing prompt data', () => { + beforeAll(() => { + wrapper = mountWithContexts( + + ); + }); + + afterAll(() => { + wrapper.unmount(); + }); + + test('should show missing resource icon', () => { + expect(wrapper.find('ExclamationTriangleIcon').length).toBe(1); + expect(wrapper.find('ScheduleToggle').prop('isDisabled')).toBe(true); + }); + }); }); diff --git a/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx b/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx index cb15696415..cc9d333fa3 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx @@ -8,7 +8,7 @@ import ErrorDetail from '../../ErrorDetail'; import useRequest from '../../../util/useRequest'; import { SchedulesAPI } from '../../../api'; -function ScheduleToggle({ schedule, onToggle, className, i18n }) { +function ScheduleToggle({ schedule, onToggle, className, i18n, isDisabled }) { const [isEnabled, setIsEnabled] = useState(schedule.enabled); const [showError, setShowError] = useState(false); @@ -55,7 +55,9 @@ function ScheduleToggle({ schedule, onToggle, className, i18n }) { labelOff={i18n._(t`Off`)} isChecked={isEnabled} isDisabled={ - isLoading || !schedule.summary_fields.user_capabilities.edit + isLoading || + !schedule.summary_fields.user_capabilities.edit || + isDisabled } onChange={toggleSchedule} aria-label={i18n._(t`Toggle schedule`)} diff --git a/awx/ui_next/src/components/Schedule/Schedules.jsx b/awx/ui_next/src/components/Schedule/Schedules.jsx index f0daa8989b..22f429dd29 100644 --- a/awx/ui_next/src/components/Schedule/Schedules.jsx +++ b/awx/ui_next/src/components/Schedule/Schedules.jsx @@ -10,6 +10,8 @@ function Schedules({ loadScheduleOptions, loadSchedules, setBreadcrumb, + launchConfig, + surveyConfig, resource, }) { const match = useRouteMatch(); @@ -17,14 +19,27 @@ function Schedules({ return ( - + - + diff --git a/awx/ui_next/src/components/Schedule/data.schedules.json b/awx/ui_next/src/components/Schedule/data.schedules.json index 13ef941811..75b1e15ebf 100644 --- a/awx/ui_next/src/components/Schedule/data.schedules.json +++ b/awx/ui_next/src/components/Schedule/data.schedules.json @@ -8,6 +8,7 @@ "rrule": "DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1", "id": 1, + "extra_data":{}, "summary_fields": { "unified_job_template": { "id": 6, @@ -27,6 +28,7 @@ "rrule": "DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1", "id": 2, + "extra_data":{}, "summary_fields": { "unified_job_template": { "id": 7, @@ -46,6 +48,7 @@ "rrule": "DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1", "id": 3, + "extra_data":{}, "summary_fields": { "unified_job_template": { "id": 8, @@ -65,6 +68,7 @@ "rrule": "DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1", "id": 4, + "extra_data":{}, "summary_fields": { "unified_job_template": { "id": 9, @@ -84,6 +88,7 @@ "rrule": "DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1", "id": 5, + "extra_data":{"novalue":null}, "summary_fields": { "unified_job_template": { "id": 10, @@ -103,4 +108,4 @@ "next_run": "2020-02-20T05:00:00Z" } ] -} \ No newline at end of file +} diff --git a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx index 35504df3b6..3088996a3d 100644 --- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx @@ -12,11 +12,7 @@ import { ActionGroup, } from '@patternfly/react-core'; import { Config } from '../../../contexts/Config'; -import { - SchedulesAPI, - JobTemplatesAPI, - WorkflowJobTemplatesAPI, -} from '../../../api'; +import { SchedulesAPI } from '../../../api'; import AnsibleSelect from '../../AnsibleSelect'; import ContentError from '../../ContentError'; import ContentLoading from '../../ContentLoading'; @@ -194,6 +190,8 @@ function ScheduleForm({ schedule, submitError, resource, + launchConfig, + surveyConfig, ...rest }) { const [isWizardOpen, setIsWizardOpen] = useState(false); @@ -210,37 +208,15 @@ function ScheduleForm({ const isTemplate = resource.type === 'workflow_job_template' || resource.type === 'job_template'; - const isWorkflowJobTemplate = - isTemplate && resource.type === 'workflow_job_template'; - const { request: loadScheduleData, error: contentError, contentLoading, - result: { zoneOptions, surveyConfig, launchConfig, credentials }, + result: { zoneOptions, credentials }, } = useRequest( useCallback(async () => { - const readLaunch = - isTemplate && - (isWorkflowJobTemplate - ? WorkflowJobTemplatesAPI.readLaunch(resource.id) - : JobTemplatesAPI.readLaunch(resource.id)); - const [{ data }, { data: launchConfiguration }] = await Promise.all([ - SchedulesAPI.readZoneInfo(), - readLaunch, - ]); + const { data } = await SchedulesAPI.readZoneInfo(); - const readSurvey = isWorkflowJobTemplate - ? WorkflowJobTemplatesAPI.readSurvey(resource.id) - : JobTemplatesAPI.readSurvey(resource.id); - - let surveyConfiguration = null; - - if (isTemplate && launchConfiguration.survey_enabled) { - const { data: survey } = await readSurvey; - - surveyConfiguration = survey; - } let creds; if (schedule.id) { const { @@ -249,37 +225,6 @@ function ScheduleForm({ creds = results; } - const missingRequiredInventory = Boolean( - !resource.inventory && !schedule?.summary_fields?.inventory.id - ); - let missingRequiredSurvey = false; - - if ( - schedule.id && - isTemplate && - !launchConfiguration?.can_start_without_user_input - ) { - missingRequiredSurvey = surveyConfiguration?.spec?.every(question => { - let hasValue; - if (Object.keys(schedule)?.length === 0) { - hasValue = true; - } - Object.entries(schedule?.extra_data).forEach(([key, value]) => { - if ( - question.required && - question.variable === key && - value.length > 0 - ) { - hasValue = false; - } - }); - return hasValue; - }); - } - if (missingRequiredInventory || missingRequiredSurvey) { - setIsSaveDisabled(true); - } - const zones = data.map(zone => { return { value: zone.name, @@ -290,18 +235,61 @@ function ScheduleForm({ return { zoneOptions: zones, - surveyConfig: surveyConfiguration || {}, - launchConfig: launchConfiguration, credentials: creds || [], }; - }, [isTemplate, isWorkflowJobTemplate, resource, schedule]), + }, [schedule]), { zonesOptions: [], - surveyConfig: {}, - launchConfig: {}, credentials: [], } ); + const missingRequiredInventory = useCallback(() => { + let missingInventory = false; + if ( + launchConfig.inventory_needed_to_start && + !schedule?.summary_fields?.inventory?.id + ) { + missingInventory = true; + } + return missingInventory; + }, [launchConfig, schedule]); + + const hasMissingSurveyValue = useCallback(() => { + let missingValues = false; + if (launchConfig?.survey_enabled) { + surveyConfig.spec.forEach(question => { + const hasDefaultValue = Boolean(question.default); + const hasSchedule = Object.keys(schedule).length; + const isRequired = question.required; + if (isRequired && !hasDefaultValue) { + if (!hasSchedule) { + missingValues = true; + } else { + const hasMatchingKey = Object.keys(schedule?.extra_data).includes( + question.variable + ); + Object.values(schedule?.extra_data).forEach(value => { + if (!value || !hasMatchingKey) { + missingValues = true; + } else { + missingValues = false; + } + }); + if (!Object.values(schedule.extra_data).length) { + missingValues = true; + } + } + } + }); + } + return missingValues; + }, [launchConfig, schedule, surveyConfig]); + + useEffect(() => { + if (isTemplate && (missingRequiredInventory() || hasMissingSurveyValue())) { + setIsSaveDisabled(true); + } + }, [isTemplate, hasMissingSurveyValue, missingRequiredInventory]); useEffect(() => { loadScheduleData(); @@ -532,6 +520,7 @@ function ScheduleForm({ > {i18n._(t`Save`)} + {isTemplate && showPromptButton && (