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`