From c608d761a2c3dacb52256dff9a554c11385f900b Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 2 Feb 2021 14:35:31 -0500 Subject: [PATCH] Adds Prompting for schedule --- .../src/components/JobList/JobListItem.jsx | 8 + .../LaunchPrompt/steps/usePreviewStep.jsx | 5 +- .../src/components/Schedule/Schedule.jsx | 13 +- .../Schedule/ScheduleAdd/ScheduleAdd.jsx | 65 ++- .../Schedule/ScheduleAdd/ScheduleAdd.test.jsx | 219 ++++++++-- .../Schedule/ScheduleEdit/ScheduleEdit.jsx | 66 ++- .../ScheduleEdit/ScheduleEdit.test.jsx | 339 ++++++++++++++- .../Schedule/shared/ScheduleForm.jsx | 236 ++++++++-- .../Schedule/shared/ScheduleForm.test.jsx | 407 +++++++++++++++++- .../shared/SchedulePromptableFields.jsx | 127 ++++++ .../Schedule/shared/useSchedulePromptSteps.js | 102 +++++ .../src/screens/Job/JobDetail/JobDetail.jsx | 5 + .../screens/Job/JobDetail/JobDetail.test.jsx | 2 + 13 files changed, 1492 insertions(+), 102 deletions(-) create mode 100644 awx/ui_next/src/components/Schedule/shared/SchedulePromptableFields.jsx create mode 100644 awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js diff --git a/awx/ui_next/src/components/JobList/JobListItem.jsx b/awx/ui_next/src/components/JobList/JobListItem.jsx index 27047015de..bd10bf6467 100644 --- a/awx/ui_next/src/components/JobList/JobListItem.jsx +++ b/awx/ui_next/src/components/JobList/JobListItem.jsx @@ -160,6 +160,14 @@ function JobListItem({ } /> )} + + {job.job_explanation && ( + + )} diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx index 8a4cc73dde..a53c6d6a6c 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx @@ -11,7 +11,8 @@ export default function usePreviewStep( resource, surveyConfig, hasErrors, - showStep + showStep, + nextButtonText ) { return { step: showStep @@ -31,7 +32,7 @@ export default function usePreviewStep( /> ), enableNext: !hasErrors, - nextButtonText: i18n._(t`Launch`), + nextButtonText: nextButtonText || i18n._(t`Launch`), } : null, initialValues: {}, diff --git a/awx/ui_next/src/components/Schedule/Schedule.jsx b/awx/ui_next/src/components/Schedule/Schedule.jsx index 201841048c..7974ba072b 100644 --- a/awx/ui_next/src/components/Schedule/Schedule.jsx +++ b/awx/ui_next/src/components/Schedule/Schedule.jsx @@ -26,12 +26,7 @@ function Schedule({ i18n, setBreadcrumb, resource }) { const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); - const { - isLoading: contentLoading, - error: contentError, - request: loadData, - result: schedule, - } = useRequest( + const { isLoading, error, request: loadData, result: schedule } = useRequest( useCallback(async () => { const { data } = await SchedulesAPI.readDetail(scheduleId); @@ -68,7 +63,7 @@ function Schedule({ i18n, setBreadcrumb, resource }) { }, ]; - if (contentLoading) { + if (isLoading) { return ; } @@ -85,8 +80,8 @@ function Schedule({ i18n, setBreadcrumb, resource }) { ); } - if (contentError) { - return ; + if (error) { + return ; } let showCardHeader = true; diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx index e6a9b81699..1fd59a91f7 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx @@ -1,12 +1,19 @@ import React, { useState } from 'react'; -import { func } from 'prop-types'; +import { func, shape } from 'prop-types'; import { withI18n } from '@lingui/react'; import { useHistory, useLocation } from 'react-router-dom'; import { RRule } from 'rrule'; import { Card } from '@patternfly/react-core'; +import yaml from 'js-yaml'; import { CardBody } from '../../Card'; +import { parseVariableField } from '../../../util/yaml'; + import buildRuleObj from '../shared/buildRuleObj'; import ScheduleForm from '../shared/ScheduleForm'; +import { SchedulesAPI } from '../../../api'; +import mergeExtraVars from '../../../util/prompt/mergeExtraVars'; +import getSurveyValues from '../../../util/prompt/getSurveyValues'; +import { getAddedAndRemoved } from '../../../util/lists'; function ScheduleAdd({ i18n, resource, apiModel }) { const [formSubmitError, setFormSubmitError] = useState(null); @@ -15,14 +22,63 @@ function ScheduleAdd({ i18n, resource, apiModel }) { const { pathname } = location; const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); - const handleSubmit = async values => { + const handleSubmit = async (values, launchConfig, surveyConfig) => { + const { + inventory, + extra_vars, + originalCredentials, + end, + frequency, + interval, + startDateTime, + timezone, + occurrences, + runOn, + runOnTheDay, + runOnTheMonth, + runOnDayMonth, + runOnDayNumber, + endDateTime, + runOnTheOccurrence, + credentials, + daysOfWeek, + ...submitValues + } = values; + const { added } = getAddedAndRemoved( + resource?.summary_fields.credentials, + credentials + ); + let extraVars; + const surveyValues = getSurveyValues(values); + const initialExtraVars = + launchConfig?.ask_variables_on_launch && (values.extra_vars || '---'); + if (surveyConfig?.spec) { + extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, surveyValues)); + } else { + extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, {})); + } + submitValues.extra_data = extraVars && parseVariableField(extraVars); + delete values.extra_vars; + if (inventory) { + submitValues.inventory = inventory.id; + } + try { const rule = new RRule(buildRuleObj(values, i18n)); - const { id: scheduleId } = await apiModel.createSchedule(resource.id, { + const { + data: { id: scheduleId }, + } = await apiModel.createSchedule(resource.id, { + ...submitValues, rrule: rule.toString().replace(/\n/g, ' '), }); - + if (credentials?.length > 0) { + await Promise.all( + added.map(({ id: credentialId }) => + SchedulesAPI.associateCredential(scheduleId, credentialId) + ) + ); + } history.push(`${pathRoot}schedules/${scheduleId}`); } catch (err) { setFormSubmitError(err); @@ -36,6 +92,7 @@ function ScheduleAdd({ i18n, resource, apiModel }) { handleCancel={() => history.push(`${pathRoot}schedules`)} handleSubmit={handleSubmit} submitError={formSubmitError} + resource={resource} /> diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx index 176462f31e..41f6b02d1d 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx @@ -5,10 +5,12 @@ import { mountWithContexts, waitForElement, } from '../../../../testUtils/enzymeHelpers'; -import { SchedulesAPI } from '../../../api'; +import { SchedulesAPI, JobTemplatesAPI, InventoriesAPI } from '../../../api'; import ScheduleAdd from './ScheduleAdd'; jest.mock('../../../api/models/Schedules'); +jest.mock('../../../api/models/JobTemplates'); +jest.mock('../../../api/models/Inventories'); SchedulesAPI.readZoneInfo.mockResolvedValue({ data: [ @@ -17,22 +19,62 @@ SchedulesAPI.readZoneInfo.mockResolvedValue({ }, ], }); +JobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: true, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: true, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + defaults: { + extra_vars: '---', + diff_mode: false, + limit: '', + job_tags: '', + skip_tags: '', + job_type: 'run', + verbosity: 0, + inventory: { + name: null, + id: null, + }, + scm_branch: '', + }, + }, +}); +JobTemplatesAPI.createSchedule.mockResolvedValue({ data: { id: 3 } }); let wrapper; -const createSchedule = jest.fn().mockImplementation(() => { - return { - data: { - id: 1, - }, - }; -}); - describe('', () => { beforeAll(async () => { await act(async () => { wrapper = mountWithContexts( - + ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); @@ -42,7 +84,7 @@ describe('', () => { }); test('Successfully creates a schedule with repeat frequency: None (run once)', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'none', @@ -52,16 +94,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run once schedule', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T100000 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', }); }); test('Successfully creates a schedule with 10 minute repeat frequency after 10 occurrences', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'after', frequency: 'minute', @@ -72,16 +115,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run every 10 minutes 10 times', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T103000 RRULE:INTERVAL=10;FREQ=MINUTELY;COUNT=10', }); }); test('Successfully creates a schedule with hourly repeat frequency ending on a specific date/time', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'onDate', endDateTime: '2020-03-26T10:45:00', @@ -92,16 +136,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run every hour until date', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=HOURLY;UNTIL=20200326T104500', }); }); test('Successfully creates a schedule with daily repeat frequency', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'day', @@ -111,16 +156,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run daily', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=DAILY', }); }); test('Successfully creates a schedule with weekly repeat frequency on mon/wed/fri', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ daysOfWeek: [RRule.MO, RRule.WE, RRule.FR], description: 'test description', end: 'never', @@ -132,15 +178,16 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run weekly on mon/wed/fri', + extra_data: {}, rrule: `DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=${RRule.MO},${RRule.WE},${RRule.FR}`, }); }); test('Successfully creates a schedule with monthly repeat frequency on the first day of the month', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'month', @@ -153,16 +200,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run on the first day of the month', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200401T104500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYMONTHDAY=1', }); }); test('Successfully creates a schedule with monthly repeat frequency on the last tuesday of the month', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', endDateTime: '2020-03-26T11:00:00', @@ -177,16 +225,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Run monthly on the last Tuesday', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200331T110000 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=TU', }); }); test('Successfully creates a schedule with yearly repeat frequency on the first day of March', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -200,16 +249,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Yearly on the first day of March', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200301T000000 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=1', }); }); test('Successfully creates a schedule with yearly repeat frequency on the second Friday in April', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -224,16 +274,17 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Yearly on the second Friday in April', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=2;BYDAY=FR;BYMONTH=4', }); }); test('Successfully creates a schedule with yearly repeat frequency on the first weekday in October', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -248,11 +299,119 @@ describe('', () => { timezone: 'America/New_York', }); }); - expect(createSchedule).toHaveBeenCalledWith({ + expect(JobTemplatesAPI.createSchedule).toHaveBeenCalledWith(700, { description: 'test description', name: 'Yearly on the first weekday in October', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=1;BYDAY=MO,TU,WE,TH,FR;BYMONTH=10', }); }); + + test('should submit prompted data properly', async () => { + InventoriesAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { + name: 'Foo', + id: 1, + url: '', + }, + { + name: 'Bar', + id: 2, + url: '', + }, + ], + }, + }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { + related_search_fields: [], + actions: { + GET: { + filterable: true, + }, + }, + }, + }); + + await act(async () => + wrapper.find('Button[aria-label="Prompt"]').prop('onClick')() + ); + wrapper.update(); + expect( + wrapper + .find('WizardNavItem') + .at(0) + .prop('isCurrent') + ).toBe(true); + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-1"]') + .simulate('change', { + target: { + checked: true, + }, + }); + }); + wrapper.update(); + expect( + wrapper + .find('input[aria-labelledby="check-action-item-1"]') + .prop('checked') + ).toBe(true); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onNext')() + ); + wrapper.update(); + expect( + wrapper + .find('WizardNavItem') + .at(1) + .prop('isCurrent') + ).toBe(true); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onNext')() + ); + wrapper.update(); + expect(wrapper.find('Wizard').length).toBe(0); + // console.log(wrapper.debug()); + await act(async () => { + wrapper.find('Formik').invoke('onSubmit')({ + name: 'Schedule', + end: 'never', + endDateTime: '2021-01-29T14:15:00', + frequency: 'none', + occurrences: 1, + runOn: 'day', + runOnDayMonth: 1, + runOnDayNumber: 1, + runOnTheDay: 'sunday', + runOnTheMonth: 1, + runOnTheOccurrence: 1, + skip_tags: '', + inventory: { name: 'inventory', id: 45 }, + credentials: [ + { name: 'cred 1', id: 10 }, + { name: 'cred 2', id: 20 }, + ], + startDateTime: '2021-01-28T14:15:00', + timezone: 'America/New_York', + }); + }); + wrapper.update(); + + expect(JobTemplatesAPI.createSchedule).toBeCalledWith(700, { + extra_data: {}, + inventory: 45, + name: 'Schedule', + rrule: + 'DTSTART;TZID=America/New_York:20210128T141500 RRULE:COUNT=1;FREQ=MINUTELY', + skip_tags: '', + }); + expect(SchedulesAPI.associateCredential).toBeCalledWith(3, 10); + expect(SchedulesAPI.associateCredential).toBeCalledWith(3, 20); + }); }); diff --git a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx index 34aaf30068..e5faf4dbeb 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx @@ -4,10 +4,16 @@ import { useHistory, useLocation } from 'react-router-dom'; import { RRule } from 'rrule'; import { shape } from 'prop-types'; import { Card } from '@patternfly/react-core'; +import yaml from 'js-yaml'; import { CardBody } from '../../Card'; import { SchedulesAPI } from '../../../api'; import buildRuleObj from '../shared/buildRuleObj'; import ScheduleForm from '../shared/ScheduleForm'; +import { getAddedAndRemoved } from '../../../util/lists'; + +import { parseVariableField } from '../../../util/yaml'; +import mergeExtraVars from '../../../util/prompt/mergeExtraVars'; +import getSurveyValues from '../../../util/prompt/getSurveyValues'; function ScheduleEdit({ i18n, schedule, resource }) { const [formSubmitError, setFormSubmitError] = useState(null); @@ -16,16 +22,69 @@ function ScheduleEdit({ i18n, schedule, resource }) { const { pathname } = location; const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); - const handleSubmit = async values => { + const handleSubmit = async ( + values, + launchConfig, + surveyConfig, + scheduleCredentials = [] + ) => { + const { + inventory, + credentials = [], + end, + frequency, + interval, + startDateTime, + timezone, + occurrences, + runOn, + runOnTheDay, + runOnTheMonth, + runOnDayMonth, + runOnDayNumber, + endDateTime, + runOnTheOccurrence, + daysOfWeek, + ...submitValues + } = values; + const { added, removed } = getAddedAndRemoved( + [...resource?.summary_fields.credentials, ...scheduleCredentials], + credentials + ); + + let extraVars; + const surveyValues = getSurveyValues(values); + const initialExtraVars = + launchConfig?.ask_variables_on_launch && (values.extra_vars || '---'); + if (surveyConfig?.spec) { + extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, surveyValues)); + } else { + extraVars = yaml.safeDump(mergeExtraVars(initialExtraVars, {})); + } + submitValues.extra_data = extraVars && parseVariableField(extraVars); + delete values.extra_vars; + if (inventory) { + submitValues.inventory = inventory.id; + } + try { const rule = new RRule(buildRuleObj(values, i18n)); const { data: { id: scheduleId }, } = await SchedulesAPI.update(schedule.id, { - name: values.name, - description: values.description, + ...submitValues, rrule: rule.toString().replace(/\n/g, ' '), }); + if (values.credentials?.length > 0) { + await Promise.all([ + ...removed.map(({ id }) => + SchedulesAPI.disassociateCredential(scheduleId, id) + ), + ...added.map(({ id }) => + SchedulesAPI.associateCredential(scheduleId, id) + ), + ]); + } history.push(`${pathRoot}schedules/${scheduleId}/details`); } catch (err) { @@ -43,6 +102,7 @@ function ScheduleEdit({ i18n, schedule, resource }) { } handleSubmit={handleSubmit} submitError={formSubmitError} + resource={resource} /> diff --git a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx index ed8b2c44e0..404f7334cb 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx @@ -5,10 +5,20 @@ import { mountWithContexts, waitForElement, } from '../../../../testUtils/enzymeHelpers'; -import { SchedulesAPI } from '../../../api'; +import { + SchedulesAPI, + JobTemplatesAPI, + InventoriesAPI, + CredentialsAPI, + CredentialTypesAPI, +} from '../../../api'; import ScheduleEdit from './ScheduleEdit'; jest.mock('../../../api/models/Schedules'); +jest.mock('../../../api/models/JobTemplates'); +jest.mock('../../../api/models/Inventories'); +jest.mock('../../../api/models/Credentials'); +jest.mock('../../../api/models/CredentialTypes'); SchedulesAPI.readZoneInfo.mockResolvedValue({ data: [ @@ -18,6 +28,75 @@ SchedulesAPI.readZoneInfo.mockResolvedValue({ ], }); +JobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: true, + ask_credential_on_launch: true, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: true, + inventory_needed_to_start: true, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + defaults: { + extra_vars: '---', + diff_mode: false, + limit: '', + job_tags: '', + skip_tags: '', + job_type: 'run', + verbosity: 0, + inventory: { + name: null, + id: null, + }, + scm_branch: '', + }, + }, +}); + +SchedulesAPI.readCredentials.mockResolvedValue({ + data: { + results: [ + { name: 'schedule credential 1', id: 1, kind: 'vault' }, + { name: 'schedule credential 2', id: 2, kind: 'aws' }, + ], + count: 2, + }, +}); + +CredentialTypesAPI.loadAllTypes.mockResolvedValue([ + { id: 1, name: 'ssh', kind: 'ssh' }, +]); + +CredentialsAPI.read.mockResolvedValue({ + data: { + count: 3, + results: [ + { id: 1, name: 'Credential 1', kind: 'ssh', url: '' }, + { id: 2, name: 'Credential 2', kind: 'ssh', url: '' }, + { id: 3, name: 'Credential 3', kind: 'ssh', url: '' }, + ], + }, +}); + +CredentialsAPI.readOptions.mockResolvedValue({ + data: { related_search_fields: [], actions: { GET: { filterabled: true } } }, +}); + SchedulesAPI.update.mockResolvedValue({ data: { id: 27, @@ -37,13 +116,14 @@ const mockSchedule = { edit: true, delete: true, }, + inventory: { id: 702, name: 'Inventory' }, }, created: '2020-04-02T18:43:12.664142Z', modified: '2020-04-02T18:43:12.664185Z', name: 'mock schedule', description: '', extra_data: {}, - inventory: null, + inventory: 1, scm_branch: null, job_type: null, job_tags: null, @@ -61,18 +141,33 @@ const mockSchedule = { }; describe('', () => { - beforeAll(async () => { + beforeEach(async () => { await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts( + + ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); }); afterEach(() => { + wrapper.unmount(); jest.clearAllMocks(); }); test('Successfully creates a schedule with repeat frequency: None (run once)', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'none', @@ -85,13 +180,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run once schedule', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T100000 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', }); }); test('Successfully creates a schedule with 10 minute repeat frequency after 10 occurrences', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'after', frequency: 'minute', @@ -105,13 +201,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run every 10 minutes 10 times', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T103000 RRULE:INTERVAL=10;FREQ=MINUTELY;COUNT=10', }); }); test('Successfully creates a schedule with hourly repeat frequency ending on a specific date/time', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'onDate', endDateTime: '2020-03-26T10:45:00', @@ -125,13 +222,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run every hour until date', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=HOURLY;UNTIL=20200326T104500', }); }); test('Successfully creates a schedule with daily repeat frequency', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'day', @@ -144,13 +242,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run daily', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=DAILY', }); }); test('Successfully creates a schedule with weekly repeat frequency on mon/wed/fri', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ daysOfWeek: [RRule.MO, RRule.WE, RRule.FR], description: 'test description', end: 'never', @@ -165,12 +264,13 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run weekly on mon/wed/fri', + extra_data: {}, rrule: `DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=${RRule.MO},${RRule.WE},${RRule.FR}`, }); }); test('Successfully creates a schedule with monthly repeat frequency on the first day of the month', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'month', @@ -186,13 +286,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run on the first day of the month', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200401T104500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYMONTHDAY=1', }); }); test('Successfully creates a schedule with monthly repeat frequency on the last tuesday of the month', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', endDateTime: '2020-03-26T11:00:00', @@ -210,13 +311,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Run monthly on the last Tuesday', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200331T110000 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=TU', }); }); test('Successfully creates a schedule with yearly repeat frequency on the first day of March', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -233,13 +335,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Yearly on the first day of March', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200301T000000 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=1', }); }); test('Successfully creates a schedule with yearly repeat frequency on the second Friday in April', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -257,13 +360,14 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Yearly on the second Friday in April', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=2;BYDAY=FR;BYMONTH=4', }); }); test('Successfully creates a schedule with yearly repeat frequency on the first weekday in October', async () => { await act(async () => { - wrapper.find('ScheduleForm').invoke('handleSubmit')({ + wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', end: 'never', frequency: 'year', @@ -281,8 +385,215 @@ describe('', () => { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', name: 'Yearly on the first weekday in October', + extra_data: {}, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=1;BYDAY=MO,TU,WE,TH,FR;BYMONTH=10', }); }); + + test('should open with correct values and navigate through the Promptable fields properly', async () => { + InventoriesAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { + name: 'Foo', + id: 1, + url: '', + }, + { + name: 'Bar', + id: 2, + url: '', + }, + ], + }, + }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { + related_search_fields: [], + actions: { + GET: { + filterable: true, + }, + }, + }, + }); + + await act(async () => + wrapper.find('Button[aria-label="Prompt"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('WizardNavItem').length).toBe(3); + expect( + wrapper + .find('WizardNavItem') + .at(0) + .prop('isCurrent') + ).toBe(true); + + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-1"]') + .simulate('change', { + target: { + checked: true, + }, + }); + }); + wrapper.update(); + + expect( + wrapper + .find('input[aria-labelledby="check-action-item-1"]') + .prop('checked') + ).toBe(true); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onNext')() + ); + wrapper.update(); + expect( + wrapper + .find('WizardNavItem') + .at(1) + .prop('isCurrent') + ).toBe(true); + + expect(wrapper.find('CredentialChip').length).toBe(3); + + wrapper.update(); + + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-3"]') + .simulate('change', { + target: { + checked: true, + }, + }); + }); + wrapper.update(); + expect( + wrapper + .find('input[aria-labelledby="check-action-item-3"]') + .prop('checked') + ).toBe(true); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onNext')() + ); + wrapper.update(); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onNext')() + ); + wrapper.update(); + expect(wrapper.find('Wizard').length).toBe(0); + await act(async () => { + wrapper.find('Formik').invoke('onSubmit')({ + name: mockSchedule.name, + end: 'never', + endDateTime: '2021-01-29T14:15:00', + frequency: 'none', + occurrences: 1, + runOn: 'day', + runOnDayMonth: 1, + runOnDayNumber: 1, + runOnTheDay: 'sunday', + runOnTheMonth: 1, + runOnTheOccurrence: 1, + skip_tags: '', + startDateTime: '2021-01-28T14:15:00', + timezone: 'America/New_York', + credentials: [ + { id: 3, name: 'Credential 3', kind: 'ssh', url: '' }, + { name: 'schedule credential 1', id: 1, kind: 'vault' }, + { name: 'schedule credential 2', id: 2, kind: 'aws' }, + ], + }); + }); + wrapper.update(); + + expect(SchedulesAPI.update).toBeCalledWith(27, { + extra_data: {}, + name: 'mock schedule', + rrule: + 'DTSTART;TZID=America/New_York:20210128T141500 RRULE:COUNT=1;FREQ=MINUTELY', + skip_tags: '', + }); + expect(SchedulesAPI.disassociateCredential).toBeCalledWith(27, 75); + + expect(SchedulesAPI.associateCredential).toBeCalledWith(27, 3); + }); + + test('should submit updated static form values, but original prompt form values', async () => { + InventoriesAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { + name: 'Foo', + id: 1, + url: '', + }, + { + name: 'Bar', + id: 2, + url: '', + }, + ], + }, + }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { + related_search_fields: [], + actions: { + GET: { + filterable: true, + }, + }, + }, + }); + await act(async () => + wrapper.find('input#schedule-name').simulate('change', { + target: { value: 'foo', name: 'name' }, + }) + ); + wrapper.update(); + await act(async () => + wrapper.find('Button[aria-label="Prompt"]').prop('onClick')() + ); + wrapper.update(); + await act(async () => { + wrapper + .find('input[aria-labelledby="check-action-item-2"]') + .simulate('change', { + target: { + checked: true, + }, + }); + }); + wrapper.update(); + + expect( + wrapper + .find('input[aria-labelledby="check-action-item-2"]') + .prop('checked') + ).toBe(true); + await act(async () => + wrapper.find('WizardFooterInternal').prop('onClose')() + ); + wrapper.update(); + expect(wrapper.find('Wizard').length).toBe(0); + + await act(async () => + wrapper.find('Button[aria-label="Save"]').prop('onClick')() + ); + expect(SchedulesAPI.update).toBeCalledWith(27, { + description: '', + extra_data: {}, + inventory: 702, + name: 'foo', + rrule: + 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', + }); + }); }); diff --git a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx index 756e400258..35504df3b6 100644 --- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx @@ -1,22 +1,36 @@ -import React, { useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback, useState } from 'react'; import { shape, func } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Formik, useField } from 'formik'; import { RRule } from 'rrule'; -import { Form, FormGroup, Title } from '@patternfly/react-core'; +import { + Button, + Form, + FormGroup, + Title, + ActionGroup, +} from '@patternfly/react-core'; import { Config } from '../../../contexts/Config'; -import { SchedulesAPI } from '../../../api'; +import { + SchedulesAPI, + JobTemplatesAPI, + WorkflowJobTemplatesAPI, +} from '../../../api'; import AnsibleSelect from '../../AnsibleSelect'; import ContentError from '../../ContentError'; import ContentLoading from '../../ContentLoading'; -import FormActionGroup from '../../FormActionGroup/FormActionGroup'; import FormField, { FormSubmitError } from '../../FormField'; -import { FormColumnLayout, SubFormLayout } from '../../FormLayout'; +import { + FormColumnLayout, + SubFormLayout, + FormFullWidthLayout, +} from '../../FormLayout'; import { dateToInputDateTime, formatDateStringUTC } from '../../../util/dates'; import useRequest from '../../../util/useRequest'; import { required } from '../../../util/validators'; import FrequencyDetailSubform from './FrequencyDetailSubform'; +import SchedulePromptableFields from './SchedulePromptableFields'; const generateRunOnTheDay = (days = []) => { if ( @@ -179,8 +193,12 @@ function ScheduleForm({ i18n, schedule, submitError, + resource, ...rest }) { + const [isWizardOpen, setIsWizardOpen] = useState(false); + const [isSaveDisabled, setIsSaveDisabled] = useState(false); + let rruleError; const now = new Date(); const closestQuarterHour = new Date( @@ -189,6 +207,123 @@ function ScheduleForm({ const tomorrow = new Date(closestQuarterHour); tomorrow.setDate(tomorrow.getDate() + 1); + const isTemplate = + resource.type === 'workflow_job_template' || + resource.type === 'job_template'; + const isWorkflowJobTemplate = + isTemplate && resource.type === 'workflow_job_template'; + + const { + request: loadScheduleData, + error: contentError, + contentLoading, + result: { zoneOptions, surveyConfig, launchConfig, credentials }, + } = useRequest( + useCallback(async () => { + const readLaunch = + isTemplate && + (isWorkflowJobTemplate + ? WorkflowJobTemplatesAPI.readLaunch(resource.id) + : JobTemplatesAPI.readLaunch(resource.id)); + const [{ data }, { data: launchConfiguration }] = await Promise.all([ + SchedulesAPI.readZoneInfo(), + readLaunch, + ]); + + const readSurvey = isWorkflowJobTemplate + ? WorkflowJobTemplatesAPI.readSurvey(resource.id) + : JobTemplatesAPI.readSurvey(resource.id); + + let surveyConfiguration = null; + + if (isTemplate && launchConfiguration.survey_enabled) { + const { data: survey } = await readSurvey; + + surveyConfiguration = survey; + } + let creds; + if (schedule.id) { + const { + data: { results }, + } = await SchedulesAPI.readCredentials(schedule.id); + creds = results; + } + + const missingRequiredInventory = Boolean( + !resource.inventory && !schedule?.summary_fields?.inventory.id + ); + let missingRequiredSurvey = false; + + if ( + schedule.id && + isTemplate && + !launchConfiguration?.can_start_without_user_input + ) { + missingRequiredSurvey = surveyConfiguration?.spec?.every(question => { + let hasValue; + if (Object.keys(schedule)?.length === 0) { + hasValue = true; + } + Object.entries(schedule?.extra_data).forEach(([key, value]) => { + if ( + question.required && + question.variable === key && + value.length > 0 + ) { + hasValue = false; + } + }); + return hasValue; + }); + } + if (missingRequiredInventory || missingRequiredSurvey) { + setIsSaveDisabled(true); + } + + const zones = data.map(zone => { + return { + value: zone.name, + key: zone.name, + label: zone.name, + }; + }); + + return { + zoneOptions: zones, + surveyConfig: surveyConfiguration || {}, + launchConfig: launchConfiguration, + credentials: creds || [], + }; + }, [isTemplate, isWorkflowJobTemplate, resource, schedule]), + { + zonesOptions: [], + surveyConfig: {}, + launchConfig: {}, + credentials: [], + } + ); + + useEffect(() => { + loadScheduleData(); + }, [loadScheduleData]); + + let showPromptButton = false; + + if ( + launchConfig && + (launchConfig.ask_inventory_on_launch || + launchConfig.ask_variables_on_launch || + launchConfig.ask_job_type_on_launch || + launchConfig.ask_limit_on_launch || + launchConfig.ask_credential_on_launch || + launchConfig.ask_scm_branch_on_launch || + launchConfig.survey_enabled || + launchConfig.inventory_needed_to_start || + launchConfig.variables_needed_to_start?.length > 0) + ) { + showPromptButton = true; + } + const initialValues = { daysOfWeek: [], description: schedule.description || '', @@ -207,6 +342,19 @@ function ScheduleForm({ startDateTime: dateToInputDateTime(closestQuarterHour), timezone: schedule.timezone || 'America/New_York', }; + const submitSchedule = ( + values, + launchConfiguration, + surveyConfiguration, + scheduleCredentials + ) => { + handleSubmit( + values, + launchConfiguration, + surveyConfiguration, + scheduleCredentials + ); + }; const overriddenValues = {}; @@ -297,28 +445,6 @@ function ScheduleForm({ } } - const { - request: loadZoneInfo, - error: contentError, - contentLoading, - result: zoneOptions, - } = useRequest( - useCallback(async () => { - const { data } = await SchedulesAPI.readZoneInfo(); - return data.map(zone => { - return { - value: zone.name, - key: zone.name, - label: zone.name, - }; - }); - }, []) - ); - - useEffect(() => { - loadZoneInfo(); - }, [loadZoneInfo]); - if (contentError || rruleError) { return ; } @@ -333,7 +459,9 @@ function ScheduleForm({ return ( { + submitSchedule(values, launchConfig, surveyConfig, credentials); + }} validate={values => { const errors = {}; const { @@ -375,11 +503,55 @@ function ScheduleForm({ zoneOptions={zoneOptions} {...rest} /> + {isWizardOpen && ( + { + setIsWizardOpen(false); + setIsSaveDisabled(hasErrors); + }} + onSave={() => { + setIsWizardOpen(false); + setIsSaveDisabled(false); + }} + /> + )} - + + + + {isTemplate && showPromptButton && ( + + )} + + + )} diff --git a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx index 771a129b9e..c8fa312dc1 100644 --- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx @@ -1,11 +1,71 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import { SchedulesAPI } from '../../../api'; + +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import { SchedulesAPI, JobTemplatesAPI, InventoriesAPI } from '../../../api'; import ScheduleForm from './ScheduleForm'; jest.mock('../../../api/models/Schedules'); +jest.mock('../../../api/models/JobTemplates'); +jest.mock('../../../api/models/Inventories'); +const survey = { + name: '', + description: '', + spec: [ + { + question_name: 'new survey', + question_description: '', + required: true, + type: 'text', + variable: 'newsurveyquestion', + min: 0, + max: 1024, + default: '', + choices: '', + new_question: false, + }, + ], +}; +const credentials = { + data: { + results: [ + { id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' }, + { id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' }, + { id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' }, + { id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' }, + { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, + ], + }, +}; +const launchData = { + data: { + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: true, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: true, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + }, +}; const mockSchedule = { rrule: 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', @@ -23,7 +83,7 @@ const mockSchedule = { name: 'mock schedule', description: 'test description', extra_data: {}, - inventory: null, + inventory: 1, scm_branch: null, job_type: null, job_tags: null, @@ -82,7 +142,11 @@ describe('', () => { ); await act(async () => { wrapper = mountWithContexts( - + ); }); wrapper.update(); @@ -92,6 +156,10 @@ describe('', () => { describe('Cancel', () => { test('should make the appropriate callback', async () => { const handleCancel = jest.fn(); + JobTemplatesAPI.readLaunch.mockResolvedValue(launchData); + + JobTemplatesAPI.readSurvey.mockResolvedValue(survey); + SchedulesAPI.readCredentials.mockResolvedValue(credentials); SchedulesAPI.readZoneInfo.mockResolvedValue({ data: [ { @@ -101,7 +169,11 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - + ); }); wrapper.update(); @@ -111,6 +183,173 @@ describe('', () => { expect(handleCancel).toHaveBeenCalledTimes(1); }); }); + describe('Prompted Schedule', () => { + let promptWrapper; + beforeEach(async () => { + JobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: false, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: true, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: true, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + }, + }); + SchedulesAPI.readZoneInfo.mockResolvedValue({ + data: [ + { + name: 'America/New_York', + }, + ], + }); + await act(async () => { + promptWrapper = mountWithContexts( + + ); + }); + waitForElement( + promptWrapper, + 'Button[aria-label="Prompt"]', + el => el.length > 0 + ); + }); + afterEach(() => { + promptWrapper.unmount(); + jest.clearAllMocks(); + }); + + test('should open prompt modal with proper steps and default values', async () => { + await act(async () => + promptWrapper.find('Button[aria-label="Prompt"]').prop('onClick')() + ); + promptWrapper.update(); + waitForElement(promptWrapper, 'Wizard', el => el.length > 0); + expect(promptWrapper.find('Wizard').length).toBe(1); + expect(promptWrapper.find('StepName#inventory-step').length).toBe(2); + expect(promptWrapper.find('StepName#preview-step').length).toBe(1); + expect(promptWrapper.find('WizardNavItem').length).toBe(2); + }); + + test('should update prompt modal data', async () => { + InventoriesAPI.read.mockResolvedValue({ + data: { + count: 2, + results: [ + { + name: 'Foo', + id: 1, + url: '', + }, + { + name: 'Bar', + id: 2, + url: '', + }, + ], + }, + }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { + related_search_fields: [], + actions: { + GET: { + filterable: true, + }, + }, + }, + }); + + await act(async () => + promptWrapper.find('Button[aria-label="Prompt"]').prop('onClick')() + ); + promptWrapper.update(); + expect( + promptWrapper + .find('WizardNavItem') + .at(0) + .prop('isCurrent') + ).toBe(true); + await act(async () => { + promptWrapper + .find('input[aria-labelledby="check-action-item-1"]') + .simulate('change', { + target: { + checked: true, + }, + }); + }); + promptWrapper.update(); + expect( + promptWrapper + .find('input[aria-labelledby="check-action-item-1"]') + .prop('checked') + ).toBe(true); + await act(async () => + promptWrapper.find('WizardFooterInternal').prop('onNext')() + ); + promptWrapper.update(); + expect( + promptWrapper + .find('WizardNavItem') + .at(1) + .prop('isCurrent') + ).toBe(true); + await act(async () => + promptWrapper.find('WizardFooterInternal').prop('onNext')() + ); + promptWrapper.update(); + expect(promptWrapper.find('Wizard').length).toBe(0); + }); + test('should render prompt button with disabled save button', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + waitForElement( + wrapper, + 'Button[aria-label="Prompt"]', + el => el.length > 0 + ); + expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe( + true + ); + }); + }); describe('Add', () => { beforeAll(async () => { SchedulesAPI.readZoneInfo.mockResolvedValue({ @@ -120,9 +359,39 @@ describe('', () => { }, ], }); + JobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: true, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: false, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: false, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + }, + }); + await act(async () => { wrapper = mountWithContexts( - + ); }); }); @@ -312,6 +581,14 @@ describe('', () => { expect(wrapper.find('select#schedule-run-on-the-month').length).toBe(1); }); test('occurrences field properly shown when end after selection is made', async () => { + await act(async () => { + wrapper + .find('FormGroup[label="Run frequency"] FormSelect') + .invoke('onChange')('minute', { + target: { value: 'minute', key: 'minute', label: 'Minute' }, + }); + }); + wrapper.update(); await act(async () => { wrapper.find('Radio#end-after').invoke('onChange')('after', { target: { name: 'end' }, @@ -331,6 +608,14 @@ describe('', () => { wrapper.update(); }); test('error shown when end date/time comes before start date/time', async () => { + await act(async () => { + wrapper + .find('FormGroup[label="Run frequency"] FormSelect') + .invoke('onChange')('minute', { + target: { value: 'minute', key: 'minute', label: 'Minute' }, + }); + }); + wrapper.update(); expect(wrapper.find('input#end-never').prop('checked')).toBe(true); expect(wrapper.find('input#end-after').prop('checked')).toBe(false); expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); @@ -361,13 +646,28 @@ describe('', () => { ); }); test('error shown when on day number is not between 1 and 31', async () => { - await act(async () => { + act(() => { + wrapper.find('select[id="schedule-frequency"]').invoke('onChange')( + { + currentTarget: { value: 'month', type: 'change' }, + target: { name: 'frequency', value: 'month' }, + }, + 'month' + ); + }); + wrapper.update(); + + act(() => { wrapper.find('input#schedule-run-on-day-number').simulate('change', { target: { value: 32, name: 'runOnDayNumber' }, }); }); wrapper.update(); + expect( + wrapper.find('input#schedule-run-on-day-number').prop('value') + ).toBe(32); + await act(async () => { wrapper.find('button[aria-label="Save"]').simulate('click'); }); @@ -379,7 +679,7 @@ describe('', () => { }); }); describe('Edit', () => { - beforeAll(async () => { + beforeEach(async () => { SchedulesAPI.readZoneInfo.mockResolvedValue({ data: [ { @@ -387,10 +687,94 @@ describe('', () => { }, ], }); + JobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: true, + passwords_needed_to_start: [], + ask_scm_branch_on_launch: false, + ask_variables_on_launch: false, + ask_tags_on_launch: false, + ask_diff_mode_on_launch: false, + ask_skip_tags_on_launch: false, + ask_job_type_on_launch: false, + ask_limit_on_launch: false, + ask_verbosity_on_launch: false, + ask_inventory_on_launch: false, + ask_credential_on_launch: false, + survey_enabled: false, + variables_needed_to_start: [], + credential_needed_to_start: false, + inventory_needed_to_start: false, + job_template_data: { + name: 'Demo Job Template', + id: 7, + description: '', + }, + }, + }); + JobTemplatesAPI.readSurvey.mockResolvedValue({}); + SchedulesAPI.readCredentials.mockResolvedValue(credentials); }); afterEach(() => { wrapper.unmount(); + jest.clearAllMocks(); }); + + test('should make API calls to fetch credentials, launch configuration, and survey configuration', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(JobTemplatesAPI.readLaunch).toBeCalledWith(23); + expect(JobTemplatesAPI.readSurvey).toBeCalledWith(23); + expect(SchedulesAPI.readCredentials).toBeCalledWith(27); + }); + + test('should not call API to get credentials ', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + + expect(SchedulesAPI.readCredentials).not.toBeCalled(); + }); + + test('should render prompt button with enabled save button for project', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + waitForElement( + wrapper, + 'Button[aria-label="Prompt"]', + el => el.length > 0 + ); + + expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe( + false + ); + }); + test('initially renders expected fields and values with existing schedule that runs once', async () => { await act(async () => { wrapper = mountWithContexts( @@ -398,6 +782,7 @@ describe('', () => { handleSubmit={jest.fn()} handleCancel={jest.fn()} schedule={mockSchedule} + resource={{ id: 23, type: 'job_template' }} /> ); }); @@ -426,6 +811,7 @@ describe('', () => { 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=10;FREQ=MINUTELY', dtend: null, })} + resource={{ id: 23, type: 'job_template' }} /> ); }); @@ -459,6 +845,7 @@ describe('', () => { dtend: '2020-04-03T03:45:00Z', until: '', })} + resource={{ id: 23, type: 'job_template' }} /> ); }); @@ -493,6 +880,7 @@ describe('', () => { dtend: null, until: '', })} + resource={{ id: 23, type: 'job_template' }} /> ); expect(wrapper.find('ScheduleForm').length).toBe(1); @@ -526,6 +914,7 @@ describe('', () => { dtend: '2020-10-30T18:45:00Z', until: '2021-01-01T00:00:00', })} + resource={{ id: 23, type: 'job_template' }} /> ); }); @@ -583,6 +972,7 @@ describe('', () => { dtend: null, until: '', })} + resource={{ id: 23, type: 'job_template' }} /> ); expect(wrapper.find('ScheduleForm').length).toBe(1); @@ -628,6 +1018,7 @@ describe('', () => { dtend: null, until: '', })} + resource={{ id: 23, type: 'job_template' }} /> ); expect(wrapper.find('ScheduleForm').length).toBe(1); diff --git a/awx/ui_next/src/components/Schedule/shared/SchedulePromptableFields.jsx b/awx/ui_next/src/components/Schedule/shared/SchedulePromptableFields.jsx new file mode 100644 index 0000000000..0b0e8d2f3c --- /dev/null +++ b/awx/ui_next/src/components/Schedule/shared/SchedulePromptableFields.jsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { Wizard } from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useFormikContext } from 'formik'; +import AlertModal from '../../AlertModal'; +import { useDismissableError } from '../../../util/useRequest'; +import ContentError from '../../ContentError'; +import ContentLoading from '../../ContentLoading'; +import useSchedulePromptSteps from './useSchedulePromptSteps'; + +function SchedulePromptableFields({ + schedule, + surveyConfig, + launchConfig, + onCloseWizard, + onSave, + credentials, + resource, + i18n, +}) { + const { + validateForm, + setFieldTouched, + values, + initialValues, + resetForm, + } = useFormikContext(); + const { + steps, + visitStep, + visitAllSteps, + validateStep, + contentError, + isReady, + } = useSchedulePromptSteps( + surveyConfig, + launchConfig, + schedule, + resource, + i18n, + credentials + ); + + const { error, dismissError } = useDismissableError(contentError); + const cancelPromptableValues = async () => { + const hasErrors = await validateForm(); + resetForm({ + values: { + ...initialValues, + daysOfWeek: values.daysOfWeek, + description: values.description, + end: values.end, + endDateTime: values.endDateTime, + frequency: values.frequency, + interval: values.interval, + name: values.name, + occurences: values.occurances, + runOn: values.runOn, + runOnDayMonth: values.runOnDayMonth, + runOnDayNumber: values.runOnDayNumber, + runOnTheDay: values.runOnTheDay, + runOnTheMonth: values.runOnTheMonth, + runOnTheOccurence: values.runOnTheOccurance, + startDateTime: values.startDateTime, + timezone: values.timezone, + }, + }); + onCloseWizard(Object.keys(hasErrors).length > 0); + }; + + if (error) { + return ( + { + dismissError(); + onCloseWizard(); + }} + > + + + ); + } + return ( + { + if (nextStep.id === 'preview') { + visitAllSteps(setFieldTouched); + } else { + visitStep(prevStep.prevId, setFieldTouched); + } + await validateForm(); + }} + onGoToStep={async (nextStep, prevStep) => { + if (nextStep.id === 'preview') { + visitAllSteps(setFieldTouched); + } else { + visitStep(prevStep.prevId, setFieldTouched); + validateStep(nextStep.id); + } + await validateForm(); + }} + title={i18n._(t`Prompts`)} + steps={ + isReady + ? steps + : [ + { + name: i18n._(t`Content Loading`), + component: , + }, + ] + } + backButtonText={i18n._(t`Back`)} + cancelButtonText={i18n._(t`Cancel`)} + nextButtonText={i18n._(t`Next`)} + /> + ); +} + +export default withI18n()(SchedulePromptableFields); diff --git a/awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js b/awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js new file mode 100644 index 0000000000..94d3dd5fb4 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js @@ -0,0 +1,102 @@ +import { useState, useEffect } from 'react'; +import { useFormikContext } from 'formik'; +import { t } from '@lingui/macro'; +import useInventoryStep from '../../LaunchPrompt/steps/useInventoryStep'; +import useCredentialsStep from '../../LaunchPrompt/steps/useCredentialsStep'; +import useOtherPromptsStep from '../../LaunchPrompt/steps/useOtherPromptsStep'; +import useSurveyStep from '../../LaunchPrompt/steps/useSurveyStep'; +import usePreviewStep from '../../LaunchPrompt/steps/usePreviewStep'; + +export default function useSchedulePromptSteps( + surveyConfig, + launchConfig, + schedule, + resource, + i18n, + scheduleCredentials +) { + const { + summary_fields: { credentials: resourceCredentials }, + } = resource; + const sourceOfValues = + (Object.keys(schedule).length > 0 && schedule) || resource; + + sourceOfValues.summary_fields = { + credentials: [...resourceCredentials, ...scheduleCredentials], + ...sourceOfValues.summary_fields, + }; + const { resetForm, values } = useFormikContext(); + const [visited, setVisited] = useState({}); + + const steps = [ + useInventoryStep(launchConfig, sourceOfValues, i18n, visited), + useCredentialsStep(launchConfig, sourceOfValues, i18n), + useOtherPromptsStep(launchConfig, sourceOfValues, i18n), + useSurveyStep(launchConfig, surveyConfig, sourceOfValues, i18n, visited), + ]; + + const hasErrors = steps.some(step => step.hasError); + steps.push( + usePreviewStep( + launchConfig, + i18n, + resource, + surveyConfig, + hasErrors, + true, + i18n._(t`Save`) + ) + ); + + const pfSteps = steps.map(s => s.step).filter(s => s != null); + const isReady = !steps.some(s => !s.isReady); + + useEffect(() => { + let initialValues = {}; + if (launchConfig && surveyConfig && isReady) { + initialValues = steps.reduce((acc, cur) => { + return { + ...acc, + ...cur.initialValues, + }; + }, {}); + } + resetForm({ + values: { + ...initialValues, + ...values, + }, + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [launchConfig, surveyConfig, isReady]); + + const stepWithError = steps.find(s => s.contentError); + const contentError = stepWithError ? stepWithError.contentError : null; + + return { + isReady, + validateStep: stepId => { + steps.find(s => s?.step?.id === stepId).validate(); + }, + steps: pfSteps, + visitStep: (prevStepId, setFieldTouched) => { + setVisited({ + ...visited, + [prevStepId]: true, + }); + steps.find(s => s?.step?.id === prevStepId).setTouched(setFieldTouched); + }, + visitAllSteps: setFieldTouched => { + setVisited({ + inventory: true, + credentials: true, + other: true, + survey: true, + preview: true, + }); + steps.forEach(s => s.setTouched(setFieldTouched)); + }, + contentError, + }; +} diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx index b50b25ea23..04063ed310 100644 --- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx +++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx @@ -326,6 +326,11 @@ function JobDetail({ job, i18n }) { user={created_by} /> + {job.extra_vars && ( ', () => { name: 'Test Source Workflow', }, }, + job_explanation: 'It failed, bummer!', }} /> ); @@ -69,6 +70,7 @@ describe('', () => { assertDetail('Job Slice', '0/1'); assertDetail('Credentials', 'SSH: Demo Credential'); assertDetail('Machine Credential', 'SSH: Machine cred'); + assertDetail('Explanation', 'It failed, bummer!'); const credentialChip = wrapper.find( `Detail[label="Credentials"] CredentialChip`