diff options
author | softwarefactory-project-zuul[bot] <33884098+softwarefactory-project-zuul[bot]@users.noreply.github.com> | 2021-02-22 14:25:12 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-02-22 14:25:12 +0100 |
commit | 1f1657d8806d243470a8dc2730bf6d15e0e3554a (patch) | |
tree | d7b234224f5a0b1e9ebd25c8906ae547869ea8cb | |
parent | Merge pull request #9369 from jbradberry/fix-broken-logos (diff) | |
parent | fixes survey row alignment issue (diff) | |
download | awx-1f1657d8806d243470a8dc2730bf6d15e0e3554a.tar.xz awx-1f1657d8806d243470a8dc2730bf6d15e0e3554a.zip |
Merge pull request #9210 from AlexSCorey/7692-PromptsOnSchedules
Prompts on schedules
Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
33 files changed, 1990 insertions, 198 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/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 && ( + <Detail + fullWidth + label={i18n._(t`Explanation`)} + value={job.job_explanation} + /> + )} </DetailList> </ExpandableRowContent> </Td> 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 ffa28dd35f..e1e59c5d85 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,40 @@ 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, + launchConfig, + surveyConfig, +}) { 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, error, 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: ( @@ -66,13 +69,13 @@ function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) { }, ]; - if (contentLoading) { + if (isLoading) { return <ContentLoading />; } if ( - schedule.summary_fields.unified_job_template.id !== - parseInt(unifiedJobTemplate.id, 10) + schedule?.summary_fields.unified_job_template.id !== + parseInt(resource.id, 10) ) { return ( <ContentError> @@ -83,16 +86,13 @@ function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) { ); } - if (contentError) { - return <ContentError error={contentError} />; + if (error) { + return <ContentError error={error} />; } let showCardHeader = true; - if ( - !location.pathname.includes('schedules/') || - location.pathname.endsWith('edit') - ) { + if (!pathname.includes('schedules/') || pathname.endsWith('edit')) { showCardHeader = false; } return ( @@ -106,18 +106,23 @@ function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) { /> {schedule && [ <Route key="edit" path={`${pathRoot}schedules/:id/edit`}> - <ScheduleEdit schedule={schedule} /> + <ScheduleEdit + schedule={schedule} + resource={resource} + launchConfig={launchConfig} + surveyConfig={surveyConfig} + /> </Route>, <Route key="details" path={`${pathRoot}schedules/:scheduleId/details`} > - <ScheduleDetail schedule={schedule} /> + <ScheduleDetail schedule={schedule} surveyConfig={surveyConfig} /> </Route>, ]} <Route key="not-found" path="*"> <ContentError> - {unifiedJobTemplate && ( + {resource && ( <Link to={`${pathRoot}details`}>{i18n._(t`View Details`)}</Link> )} </ContentError> 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('<Schedule />', () => { <Route path="/templates/job_template/:id/schedules" component={() => ( - <Schedule - setBreadcrumb={() => {}} - unifiedJobTemplate={unifiedJobTemplate} - /> + <Schedule setBreadcrumb={() => {}} 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..81f42f5b72 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx @@ -1,31 +1,89 @@ 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, createSchedule }) { +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 => { + const handleSubmit = async ( + values, + launchConfiguration, + surveyConfiguration + ) => { + 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 = + 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); + delete values.extra_vars; + if (inventory) { + submitValues.inventory = inventory.id; + } + try { const rule = new RRule(buildRuleObj(values, i18n)); + const { data: { id: scheduleId }, - } = await createSchedule({ - name: values.name, - description: values.description, + } = 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); @@ -39,6 +97,9 @@ function ScheduleAdd({ i18n, createSchedule }) { handleCancel={() => history.push(`${pathRoot}schedules`)} handleSubmit={handleSubmit} submitError={formSubmitError} + launchConfig={launchConfig} + surveyConfig={surveyConfig} + resource={resource} /> </CardBody> </Card> @@ -46,7 +107,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/ScheduleAdd/ScheduleAdd.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx index 176462f31e..970bb91476 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: [ @@ -18,21 +20,62 @@ SchedulesAPI.readZoneInfo.mockResolvedValue({ ], }); -let wrapper; - -const createSchedule = jest.fn().mockImplementation(() => { - return { - data: { - id: 1, +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; describe('<ScheduleAdd />', () => { beforeAll(async () => { await act(async () => { wrapper = mountWithContexts( - <ScheduleAdd createSchedule={createSchedule} /> + <ScheduleAdd + apiModel={JobTemplatesAPI} + resource={{ + id: 700, + type: 'job_template', + inventory: 2, + summary_fields: { credentials: [] }, + }} + launchConfig={launchConfig} + /> ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); @@ -42,7 +85,7 @@ describe('<ScheduleAdd />', () => { }); 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 +95,17 @@ describe('<ScheduleAdd />', () => { 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 +116,17 @@ describe('<ScheduleAdd />', () => { 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 +137,17 @@ describe('<ScheduleAdd />', () => { 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 +157,17 @@ describe('<ScheduleAdd />', () => { 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 +179,16 @@ describe('<ScheduleAdd />', () => { 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 +201,17 @@ describe('<ScheduleAdd />', () => { 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 +226,17 @@ describe('<ScheduleAdd />', () => { 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 +250,17 @@ describe('<ScheduleAdd />', () => { 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 +275,17 @@ describe('<ScheduleAdd />', () => { 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 +300,118 @@ describe('<ScheduleAdd />', () => { 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); + 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/ScheduleDetail/ScheduleDetail.jsx b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx index 946ac94f55..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; @@ -199,7 +235,11 @@ function ScheduleDetail({ schedule, i18n }) { return ( <CardBody> - <ScheduleToggle schedule={schedule} css="padding-bottom: 40px" /> + <ScheduleToggle + schedule={schedule} + css="padding-bottom: 40px" + isDisabled={isDisabled} + /> <DetailList gutter="sm"> <Detail label={i18n._(t`Name`)} value={name} /> <Detail label={i18n._(t`Description`)} value={description} /> @@ -256,6 +296,12 @@ function ScheduleDetail({ schedule, i18n }) { } /> )} + {ask_verbosity_on_launch && ( + <Detail + label={i18n._(t`Verbosity`)} + value={VERBOSITY[verbosity]} + /> + )} {ask_scm_branch_on_launch && ( <Detail label={i18n._(t`Source Control Branch`)} @@ -265,12 +311,6 @@ function ScheduleDetail({ schedule, i18n }) { {ask_limit_on_launch && ( <Detail label={i18n._(t`Limit`)} value={limit} /> )} - {ask_verbosity_on_launch && ( - <Detail - label={i18n._(t`Verbosity`)} - value={VERBOSITY[verbosity]} - /> - )} {showDiffModeDetail && ( <Detail label={i18n._(t`Show Changes`)} diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.test.jsx index 920a7029fd..b0230909a7 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.test.jsx @@ -26,6 +26,7 @@ const allPrompts = { ask_variables_on_launch: true, ask_verbosity_on_launch: true, survey_enabled: true, + inventory_needed_to_start: true, }, }; @@ -489,4 +490,39 @@ describe('<ScheduleDetail />', () => { ); 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( + <Route + path="/templates/job_template/:id/schedules/:scheduleId" + component={() => ( + <ScheduleDetail schedule={schedule} surveyConfig={{ spec: [] }} /> + )} + />, + { + 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 5887ef9afa..9f11e8676b 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx @@ -4,28 +4,101 @@ 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'; -function ScheduleEdit({ i18n, schedule }) { +import { parseVariableField } from '../../../util/yaml'; +import mergeExtraVars from '../../../util/prompt/mergeExtraVars'; +import getSurveyValues from '../../../util/prompt/getSurveyValues'; + +function ScheduleEdit({ + i18n, + schedule, + resource, + 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 => { + const handleSubmit = async ( + values, + launchConfiguration, + surveyConfiguration, + scheduleCredentials = [] + ) => { + const { + inventory, + credentials = [], + end, + frequency, + interval, + startDateTime, + timezone, + occurences, + runOn, + runOnTheDay, + runOnTheMonth, + runOnDayMonth, + runOnDayNumber, + endDateTime, + runOnTheOccurence, + daysOfWeek, + ...submitValues + } = values; + const { added, removed } = getAddedAndRemoved( + [...(resource?.summary_fields.credentials || []), ...scheduleCredentials], + credentials + ); + + let extraVars; + const surveyValues = getSurveyValues(values); + const initialExtraVars = + 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; + } + 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 +116,9 @@ function ScheduleEdit({ i18n, schedule }) { } handleSubmit={handleSubmit} submitError={formSubmitError} + resource={resource} + launchConfig={launchConfig} + surveyConfig={surveyConfig} /> </CardBody> </Card> 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..c70eccad6a 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,19 @@ import { mountWithContexts, waitForElement, } from '../../../../testUtils/enzymeHelpers'; -import { SchedulesAPI } from '../../../api'; +import { + SchedulesAPI, + 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 +27,35 @@ SchedulesAPI.readZoneInfo.mockResolvedValue({ ], }); +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 +75,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 +100,71 @@ const mockSchedule = { }; describe('<ScheduleEdit />', () => { - beforeAll(async () => { + beforeEach(async () => { await act(async () => { - wrapper = mountWithContexts(<ScheduleEdit schedule={mockSchedule} />); + wrapper = mountWithContexts( + <ScheduleEdit + schedule={mockSchedule} + resource={{ + id: 700, + type: 'job_template', + iventory: 1, + summary_fields: { + credentials: [ + { name: 'job template credential', id: 75, kind: 'ssh' }, + ], + }, + }} + 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={{}} + /> + ); }); 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 +177,14 @@ describe('<ScheduleEdit />', () => { 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 +198,15 @@ describe('<ScheduleEdit />', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { 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', }); }); 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 +220,14 @@ describe('<ScheduleEdit />', () => { 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 +240,14 @@ describe('<ScheduleEdit />', () => { 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 +262,14 @@ describe('<ScheduleEdit />', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { 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}`, }); }); 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 +285,15 @@ describe('<ScheduleEdit />', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { 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', }); }); 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,16 @@ describe('<ScheduleEdit />', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { 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', }); }); 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 +337,15 @@ describe('<ScheduleEdit />', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { 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', }); }); 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 +363,16 @@ describe('<ScheduleEdit />', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { 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', }); }); 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 +390,221 @@ describe('<ScheduleEdit />', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { 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', }); }); + + 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', + occurrences: 1, + runOnTheOccurrence: 1, + 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: {}, + 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( <ScheduleList loadSchedules={loadSchedules} loadScheduleOptions={loadScheduleOptions} + resource={{ type: 'job_template', inventory: 1 }} + launchConfig={{ survey_enabled: false }} + surveyConfig={{}} /> ); }); 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( + <ScheduleList + loadSchedules={loadSchedules} + loadScheduleOptions={loadScheduleOptions} + hideAddButton + resource={{ type: 'job_template', inventory: 1 }} + launchConfig={{ survey_enabled: true }} + surveyConfig={{ spec: [{ required: true, default: null }] }} + /> + ); + }); + 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( + <ScheduleList + loadSchedules={loadSchedules} + loadScheduleOptions={loadScheduleOptions} + hideAddButton + resource={{ type: 'job_template' }} + launchConfig={{ + survey_enabled: true, + inventory_needed_to_start: true, + }} + surveyConfig={{ spec: [] }} + /> + ); + }); + 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 ( <Tr id={`schedule-row-${schedule.id}`}> @@ -61,6 +79,18 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) { <Link to={`${scheduleBaseUrl}/details`}> <b>{schedule.name}</b> </Link> + {Boolean(isMissingInventory || isMissingSurvey) && ( + <span> + <Tooltip + content={[isMissingInventory, isMissingSurvey].map(message => ( + <div key={message}>{message}</div> + ))} + position="right" + > + <ExclamationTriangleIcon /> + </Tooltip> + </span> + )} </Td> <Td dataLabel={i18n._(t`Type`)}> { @@ -80,7 +110,7 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) { )} </Td> <ActionsTd dataLabel={i18n._(t`Actions`)} gridColumns="auto 40px"> - <ScheduleToggle schedule={schedule} /> + <ScheduleToggle schedule={schedule} isDisabled={isDisabled} /> <ActionItem visible={schedule.summary_fields.user_capabilities.edit} tooltip={i18n._(t`Edit Schedule`)} diff --git a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.test.jsx index 2e01a3764e..ccf5fa8855 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleListItem.test.jsx @@ -50,15 +50,13 @@ describe('ScheduleListItem', () => { describe('User has edit permissions', () => { beforeAll(() => { wrapper = mountWithContexts( - <table> - <tbody> - <ScheduleListItem - isSelected={false} - onSelect={onSelect} - schedule={mockSchedule} - /> - </tbody> - </table> + <ScheduleListItem + isSelected={false} + onSelect={onSelect} + schedule={mockSchedule} + isMissingSurvey={false} + isMissingInventory={false} + /> ); }); @@ -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( + <ScheduleListItem + isSelected={false} + onSelect={onSelect} + schedule={{ + ...mockSchedule, + summary_fields: { + ...mockSchedule.summary_fields, + user_capabilities: { + edit: false, + delete: false, + }, + }, + }} + isMissingInventory="Inventory Error" + isMissingSurvey="Survey Error" + /> + ); + }); + + 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 b9da804e63..22f429dd29 100644 --- a/awx/ui_next/src/components/Schedule/Schedules.jsx +++ b/awx/ui_next/src/components/Schedule/Schedules.jsx @@ -6,28 +6,40 @@ import ScheduleAdd from './ScheduleAdd'; import ScheduleList from './ScheduleList'; function Schedules({ - createSchedule, + apiModel, loadScheduleOptions, loadSchedules, setBreadcrumb, - unifiedJobTemplate, + launchConfig, + surveyConfig, + resource, }) { const match = useRouteMatch(); return ( <Switch> <Route path={`${match.path}/add`}> - <ScheduleAdd createSchedule={createSchedule} /> + <ScheduleAdd + apiModel={apiModel} + resource={resource} + launchConfig={launchConfig} + surveyConfig={surveyConfig} + /> </Route> <Route key="details" path={`${match.path}/:scheduleId`}> <Schedule - unifiedJobTemplate={unifiedJobTemplate} setBreadcrumb={setBreadcrumb} + resource={resource} + launchConfig={launchConfig} + surveyConfig={surveyConfig} /> </Route> <Route key="list" path={`${match.path}`}> <ScheduleList + resource={resource} loadSchedules={loadSchedules} + launchConfig={launchConfig} + surveyConfig={surveyConfig} loadScheduleOptions={loadScheduleOptions} /> </Route> 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 756e400258..3088996a3d 100644 --- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx @@ -1,22 +1,32 @@ -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 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 +189,14 @@ function ScheduleForm({ i18n, schedule, submitError, + resource, + launchConfig, + surveyConfig, ...rest }) { + const [isWizardOpen, setIsWizardOpen] = useState(false); + const [isSaveDisabled, setIsSaveDisabled] = useState(false); + let rruleError; const now = new Date(); const closestQuarterHour = new Date( @@ -189,6 +205,113 @@ function ScheduleForm({ const tomorrow = new Date(closestQuarterHour); tomorrow.setDate(tomorrow.getDate() + 1); + const isTemplate = + resource.type === 'workflow_job_template' || + resource.type === 'job_template'; + const { + request: loadScheduleData, + error: contentError, + contentLoading, + result: { zoneOptions, credentials }, + } = useRequest( + useCallback(async () => { + const { data } = await SchedulesAPI.readZoneInfo(); + + let creds; + if (schedule.id) { + const { + data: { results }, + } = await SchedulesAPI.readCredentials(schedule.id); + creds = results; + } + + const zones = data.map(zone => { + return { + value: zone.name, + key: zone.name, + label: zone.name, + }; + }); + + return { + zoneOptions: zones, + credentials: creds || [], + }; + }, [schedule]), + { + zonesOptions: [], + 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(); + }, [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 +330,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 +433,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 <ContentError error={contentError || rruleError} />; } @@ -333,7 +447,9 @@ function ScheduleForm({ return ( <Formik initialValues={Object.assign(initialValues, overriddenValues)} - onSubmit={handleSubmit} + onSubmit={values => { + submitSchedule(values, launchConfig, surveyConfig, credentials); + }} validate={values => { const errors = {}; const { @@ -375,11 +491,56 @@ function ScheduleForm({ zoneOptions={zoneOptions} {...rest} /> + {isWizardOpen && ( + <SchedulePromptableFields + schedule={schedule} + credentials={credentials} + surveyConfig={surveyConfig} + launchConfig={launchConfig} + resource={resource} + onCloseWizard={hasErrors => { + setIsWizardOpen(false); + setIsSaveDisabled(hasErrors); + }} + onSave={() => { + setIsWizardOpen(false); + setIsSaveDisabled(false); + }} + /> + )} <FormSubmitError error={submitError} /> - <FormActionGroup - onCancel={handleCancel} - onSubmit={formik.handleSubmit} - /> + <FormFullWidthLayout> + <ActionGroup> + <Button + aria-label={i18n._(t`Save`)} + variant="primary" + type="button" + onClick={formik.handleSubmit} + isDisabled={isSaveDisabled} + > + {i18n._(t`Save`)} + </Button> + + {isTemplate && showPromptButton && ( + <Button + variant="secondary" + type="button" + aria-label={i18n._(t`Prompt`)} + onClick={() => setIsWizardOpen(true)} + > + {i18n._(t`Prompt`)} + </Button> + )} + <Button + aria-label={i18n._(t`Cancel`)} + variant="secondary" + type="button" + onClick={handleCancel} + > + {i18n._(t`Cancel`)} + </Button> + </ActionGroup> + </FormFullWidthLayout> </FormColumnLayout> </Form> )} 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..508169ce3e 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,53 @@ 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 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 +65,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 +124,34 @@ describe('<ScheduleForm />', () => { ); await act(async () => { wrapper = mountWithContexts( - <ScheduleForm handleSubmit={jest.fn()} handleCancel={jest.fn()} /> + <ScheduleForm + handleSubmit={jest.fn()} + handleCancel={jest.fn()} + 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: '', + }, + }} + resource={{ id: 23, type: 'job_template' }} + /> ); }); wrapper.update(); @@ -92,6 +161,9 @@ describe('<ScheduleForm />', () => { describe('Cancel', () => { test('should make the appropriate callback', async () => { const handleCancel = jest.fn(); + JobTemplatesAPI.readLaunch.mockResolvedValue(launchData); + + SchedulesAPI.readCredentials.mockResolvedValue(credentials); SchedulesAPI.readZoneInfo.mockResolvedValue({ data: [ { @@ -101,7 +173,34 @@ describe('<ScheduleForm />', () => { }); await act(async () => { wrapper = mountWithContexts( - <ScheduleForm handleSubmit={jest.fn()} handleCancel={handleCancel} /> + <ScheduleForm + handleSubmit={jest.fn()} + handleCancel={handleCancel} + 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: '', + }, + }} + resource={{ id: 23, type: 'job_template', inventory: 1 }} + /> ); }); wrapper.update(); @@ -111,6 +210,201 @@ describe('<ScheduleForm />', () => { expect(handleCancel).toHaveBeenCalledTimes(1); }); }); + describe('Prompted Schedule', () => { + let promptWrapper; + beforeEach(async () => { + SchedulesAPI.readZoneInfo.mockResolvedValue({ + data: [ + { + name: 'America/New_York', + }, + ], + }); + await act(async () => { + promptWrapper = mountWithContexts( + <ScheduleForm + handleSubmit={jest.fn()} + handleCancel={jest.fn()} + resource={{ + id: 23, + type: 'job_template', + inventory: 1, + summary_fields: { + credentials: [], + }, + }} + 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: '', + }, + }} + surveyConfig={{ spec: [{ required: true, default: '' }] }} + /> + ); + }); + 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 render disabled save button due to missing required surevy values', () => { + expect( + promptWrapper.find('Button[aria-label="Save"]').prop('isDisabled') + ).toBe(true); + }); + + 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( + <ScheduleForm + handleSubmit={jest.fn()} + handleCancel={jest.fn()} + resource={{ + id: 23, + type: 'job_template', + }} + 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: '', + }, + }} + /> + ); + }); + 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 +414,37 @@ describe('<ScheduleForm />', () => { }, ], }); + await act(async () => { wrapper = mountWithContexts( - <ScheduleForm handleSubmit={jest.fn()} handleCancel={jest.fn()} /> + <ScheduleForm + handleSubmit={jest.fn()} + handleCancel={jest.fn()} + resource={{ id: 23, type: 'job_template', inventory: 1 }} + launchConfig={{ + 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: '', + }, + }} + /> ); }); }); @@ -313,6 +635,14 @@ describe('<ScheduleForm />', () => { }); 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 +661,14 @@ describe('<ScheduleForm />', () => { 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 +699,28 @@ describe('<ScheduleForm />', () => { ); }); 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 +732,7 @@ describe('<ScheduleForm />', () => { }); }); describe('Edit', () => { - beforeAll(async () => { + beforeEach(async () => { SchedulesAPI.readZoneInfo.mockResolvedValue({ data: [ { @@ -387,10 +740,113 @@ describe('<ScheduleForm />', () => { }, ], }); + + 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( + <ScheduleForm + handleSubmit={jest.fn()} + handleCancel={jest.fn()} + schedule={{ inventory: null, ...mockSchedule }} + resource={{ id: 23, type: 'job_template' }} + launchConfig={{ + 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: '', + }, + }} + /> + ); + }); + expect(SchedulesAPI.readCredentials).toBeCalledWith(27); + }); + + test('should not call API to get credentials ', async () => { + await act(async () => { + wrapper = mountWithContexts( + <ScheduleForm + handleSubmit={jest.fn()} + handleCancel={jest.fn()} + resource={{ id: 23, type: 'job_template' }} + launchConfig={{ + 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: '', + }, + }} + /> + ); + }); + + expect(SchedulesAPI.readCredentials).not.toBeCalled(); }); + + test('should render prompt button with enabled save button for project', async () => { + await act(async () => { + wrapper = mountWithContexts( + <ScheduleForm + handleSubmit={jest.fn()} + handleCancel={jest.fn()} + resource={{ + id: 23, + type: 'project', + inventory: 2, + }} + /> + ); + }); + 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 +854,8 @@ describe('<ScheduleForm />', () => { handleSubmit={jest.fn()} handleCancel={jest.fn()} schedule={mockSchedule} + launchConfig={{ inventory_needed_to_start: false }} + resource={{ id: 23, type: 'job_template' }} /> ); }); @@ -421,11 +879,13 @@ describe('<ScheduleForm />', () => { <ScheduleForm handleSubmit={jest.fn()} handleCancel={jest.fn()} + launchConfig={{ inventory_needed_to_start: false }} schedule={Object.assign(mockSchedule, { rrule: 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=10;FREQ=MINUTELY', dtend: null, })} + resource={{ id: 23, type: 'job_template' }} /> ); }); @@ -453,12 +913,14 @@ describe('<ScheduleForm />', () => { <ScheduleForm handleSubmit={jest.fn()} handleCancel={jest.fn()} + launchConfig={{ inventory_needed_to_start: false }} schedule={Object.assign(mockSchedule, { rrule: 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;FREQ=HOURLY;COUNT=10', dtend: '2020-04-03T03:45:00Z', until: '', })} + resource={{ id: 23, type: 'job_template' }} /> ); }); @@ -487,12 +949,14 @@ describe('<ScheduleForm />', () => { <ScheduleForm handleSubmit={jest.fn()} handleCancel={jest.fn()} + launchConfig={{ inventory_needed_to_start: false }} schedule={Object.assign(mockSchedule, { rrule: 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;FREQ=DAILY', dtend: null, until: '', })} + resource={{ id: 23, type: 'job_template' }} /> ); expect(wrapper.find('ScheduleForm').length).toBe(1); @@ -520,12 +984,14 @@ describe('<ScheduleForm />', () => { <ScheduleForm handleSubmit={jest.fn()} handleCancel={jest.fn()} + launchConfig={{ inventory_needed_to_start: false }} schedule={Object.assign(mockSchedule, { rrule: 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20210101T050000Z', dtend: '2020-10-30T18:45:00Z', until: '2021-01-01T00:00:00', })} + resource={{ id: 23, type: 'job_template' }} /> ); }); @@ -577,12 +1043,14 @@ describe('<ScheduleForm />', () => { <ScheduleForm handleSubmit={jest.fn()} handleCancel={jest.fn()} + launchConfig={{ inventory_needed_to_start: false }} schedule={Object.assign(mockSchedule, { rrule: 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=MO,TU,WE,TH,FR', dtend: null, until: '', })} + resource={{ id: 23, type: 'job_template' }} /> ); expect(wrapper.find('ScheduleForm').length).toBe(1); @@ -622,12 +1090,14 @@ describe('<ScheduleForm />', () => { <ScheduleForm handleSubmit={jest.fn()} handleCancel={jest.fn()} + launchConfig={{ inventory_needed_to_start: false }} schedule={Object.assign(mockSchedule, { rrule: 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=6', 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 ( + <AlertModal + isOpen={error} + variant="error" + title={i18n._(t`Error!`)} + onClose={() => { + dismissError(); + onCloseWizard(); + }} + > + <ContentError error={error} /> + </AlertModal> + ); + } + return ( + <Wizard + isOpen + onClose={cancelPromptableValues} + onSave={onSave} + onNext={async (nextStep, prevStep) => { + 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: <ContentLoading />, + }, + ] + } + 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..841aeefa92 --- /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/Inventory/InventorySource/InventorySource.jsx b/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.jsx index dec808d20e..a8f56e2aa8 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.jsx @@ -69,9 +69,6 @@ function InventorySource({ i18n, inventory, setBreadcrumb, me }) { [source] ); - const createSchedule = data => - 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" > <Schedules - createSchedule={createSchedule} - setBreadcrumb={(unifiedJobTemplate, schedule) => + 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 }) { <Route path="/projects/:id/schedules"> <Schedules setBreadcrumb={setBreadcrumb} - unifiedJobTemplate={project} - createSchedule={createSchedule} + resource={project} + apiModel={ProjectsAPI} loadSchedules={loadSchedules} loadScheduleOptions={loadScheduleOptions} /> diff --git a/awx/ui_next/src/screens/Template/Survey/SurveyListItem.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyListItem.jsx index 487488cb2e..076c9629d6 100644 --- a/awx/ui_next/src/screens/Template/Survey/SurveyListItem.jsx +++ b/awx/ui_next/src/screens/Template/Survey/SurveyListItem.jsx @@ -20,10 +20,12 @@ import DataListCell from '../../../components/DataListCell'; import ChipGroup from '../../../components/ChipGroup'; const DataListAction = styled(_DataListAction)` - margin-left: 0; - margin-right: 20px; - padding-top: 15px; - padding-bottom: 15px; + && { + margin-left: 0; + margin-right: 20px; + padding-top: 0; + padding-bottom: 0; + } `; const Button = styled(_Button)` padding-top: 0; diff --git a/awx/ui_next/src/screens/Template/Template.jsx b/awx/ui_next/src/screens/Template/Template.jsx index 50b238b3b7..4bc2216c22 100644 --- a/awx/ui_next/src/screens/Template/Template.jsx +++ b/awx/ui_next/src/screens/Template/Template.jsx @@ -32,20 +32,33 @@ function Template({ i18n, setBreadcrumb }) { const { me = {} } = useConfig(); const { - result: { isNotifAdmin, template }, + result: { isNotifAdmin, template, surveyConfig, launchConfig }, isLoading, error: contentError, request: loadTemplateAndRoles, } = useRequest( useCallback(async () => { - const [{ data }, actions, notifAdminRes] = await Promise.all([ + const [ + { data }, + actions, + notifAdminRes, + { data: launchConfiguration }, + ] = await Promise.all([ JobTemplatesAPI.readDetail(templateId), JobTemplatesAPI.readTemplateOptions(templateId), OrganizationsAPI.read({ page_size: 1, role_level: 'notification_admin_role', }), + JobTemplatesAPI.readLaunch(templateId), ]); + let surveyConfiguration = null; + + if (data.survey_enabled) { + const { data: survey } = await JobTemplatesAPI.readSurvey(templateId); + + surveyConfiguration = survey; + } if (data.summary_fields.credentials) { const params = { page: 1, @@ -71,6 +84,8 @@ function Template({ i18n, setBreadcrumb }) { return { template: data, isNotifAdmin: notifAdminRes.data.results.length > 0, + surveyConfig: surveyConfiguration, + launchConfig: launchConfiguration, }; }, [templateId]), { isNotifAdmin: false, template: null } @@ -86,10 +101,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,11 +214,13 @@ function Template({ i18n, setBreadcrumb }) { path="/templates/:templateType/:id/schedules" > <Schedules - createSchedule={createSchedule} + apiModel={JobTemplatesAPI} setBreadcrumb={setBreadcrumb} - unifiedJobTemplate={template} + resource={template} loadSchedules={loadSchedules} loadScheduleOptions={loadScheduleOptions} + surveyConfig={surveyConfig} + launchConfig={launchConfig} /> </Route> {canSeeNotificationsTab && ( diff --git a/awx/ui_next/src/screens/Template/Template.test.jsx b/awx/ui_next/src/screens/Template/Template.test.jsx index afb7221f06..d0e370a697 100644 --- a/awx/ui_next/src/screens/Template/Template.test.jsx +++ b/awx/ui_next/src/screens/Template/Template.test.jsx @@ -21,7 +21,7 @@ describe('<Template />', () => { let wrapper; beforeEach(() => { JobTemplatesAPI.readDetail.mockResolvedValue({ - data: mockJobTemplateData, + data: { ...mockJobTemplateData, survey_enabled: false }, }); JobTemplatesAPI.readTemplateOptions.mockResolvedValue({ data: { @@ -56,6 +56,7 @@ describe('<Template />', () => { ], }, }); + JobTemplatesAPI.readLaunch.mockResolvedValue({ data: {} }); JobTemplatesAPI.readWebhookKey.mockResolvedValue({ data: { webhook_key: 'key', diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx index ee22010983..6bfa39c6c8 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx @@ -36,21 +36,37 @@ function WorkflowJobTemplate({ i18n, setBreadcrumb }) { const { me = {} } = useConfig(); const { - result: { isNotifAdmin, template }, + result: { isNotifAdmin, template, surveyConfig, launchConfig }, isLoading: hasRolesandTemplateLoading, error: rolesAndTemplateError, request: loadTemplateAndRoles, } = useRequest( useCallback(async () => { - const [{ data }, actions, notifAdminRes] = await Promise.all([ + const [ + { data }, + actions, + notifAdminRes, + { data: launchConfiguration }, + ] = await Promise.all([ WorkflowJobTemplatesAPI.readDetail(templateId), WorkflowJobTemplatesAPI.readWorkflowJobTemplateOptions(templateId), OrganizationsAPI.read({ page_size: 1, role_level: 'notification_admin_role', }), + WorkflowJobTemplatesAPI.readLaunch(templateId), ]); + let surveyConfiguration = null; + + if (data.survey_enabled) { + const { data: survey } = await WorkflowJobTemplatesAPI.readSurvey( + templateId + ); + + surveyConfiguration = survey; + } + if (actions.data.actions.PUT) { if (data.webhook_service && data?.related?.webhook_key) { const { @@ -65,6 +81,8 @@ function WorkflowJobTemplate({ i18n, setBreadcrumb }) { return { template: data, isNotifAdmin: notifAdminRes.data.results.length > 0, + launchConfig: launchConfiguration, + surveyConfig: surveyConfiguration, }; }, [setBreadcrumb, templateId]), { isNotifAdmin: false, template: null } @@ -73,10 +91,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,11 +220,13 @@ function WorkflowJobTemplate({ i18n, setBreadcrumb }) { path="/templates/:templateType/:id/schedules" > <Schedules - createSchedule={createSchedule} + apiModel={WorkflowJobTemplatesAPI} setBreadcrumb={setBreadcrumb} - unifiedJobTemplate={template} + resource={template} loadSchedules={loadSchedules} loadScheduleOptions={loadScheduleOptions} + surveyConfig={surveyConfig} + launchConfig={launchConfig} /> </Route> )} diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.test.jsx index 5694764058..17febc6af1 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.test.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.test.jsx @@ -26,7 +26,7 @@ describe('<WorkflowJobTemplate />', () => { let wrapper; beforeEach(() => { WorkflowJobTemplatesAPI.readDetail.mockResolvedValue({ - data: mockWorkflowJobTemplateData, + data: { ...mockWorkflowJobTemplateData, survey_enabled: false }, }); WorkflowJobTemplatesAPI.readWorkflowJobTemplateOptions.mockResolvedValue({ data: { @@ -45,6 +45,7 @@ describe('<WorkflowJobTemplate />', () => { ], }, }); + WorkflowJobTemplatesAPI.readLaunch.mockResolvedValue({ data: {} }); WorkflowJobTemplatesAPI.readWebhookKey.mockResolvedValue({ data: { webhook_key: 'key', |