@@ -61,6 +79,18 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) {
{schedule.name}
+ {Boolean(isMissingInventory || isMissingSurvey) && (
+
+ (
+ {message}
+ ))}
+ position="right"
+ >
+
+
+
+ )}
{
@@ -80,7 +110,7 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) {
)}
|
-
+
{
describe('User has edit permissions', () => {
beforeAll(() => {
wrapper = mountWithContexts(
-
+
);
});
@@ -118,6 +116,9 @@ describe('ScheduleListItem', () => {
.simulate('change');
expect(onSelect).toHaveBeenCalledTimes(1);
});
+ test('Toggle button is enabled', () => {
+ expect(wrapper.find('ScheduleToggle').prop('isDisabled')).toBe(false);
+ });
});
describe('User has read-only permissions', () => {
@@ -186,4 +187,35 @@ describe('ScheduleListItem', () => {
).toBe(true);
});
});
+ describe('schedule has missing prompt data', () => {
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+
+ afterAll(() => {
+ wrapper.unmount();
+ });
+
+ test('should show missing resource icon', () => {
+ expect(wrapper.find('ExclamationTriangleIcon').length).toBe(1);
+ expect(wrapper.find('ScheduleToggle').prop('isDisabled')).toBe(true);
+ });
+ });
});
diff --git a/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx b/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx
index cb15696415..cc9d333fa3 100644
--- a/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx
+++ b/awx/ui_next/src/components/Schedule/ScheduleToggle/ScheduleToggle.jsx
@@ -8,7 +8,7 @@ import ErrorDetail from '../../ErrorDetail';
import useRequest from '../../../util/useRequest';
import { SchedulesAPI } from '../../../api';
-function ScheduleToggle({ schedule, onToggle, className, i18n }) {
+function ScheduleToggle({ schedule, onToggle, className, i18n, isDisabled }) {
const [isEnabled, setIsEnabled] = useState(schedule.enabled);
const [showError, setShowError] = useState(false);
@@ -55,7 +55,9 @@ function ScheduleToggle({ schedule, onToggle, className, i18n }) {
labelOff={i18n._(t`Off`)}
isChecked={isEnabled}
isDisabled={
- isLoading || !schedule.summary_fields.user_capabilities.edit
+ isLoading ||
+ !schedule.summary_fields.user_capabilities.edit ||
+ isDisabled
}
onChange={toggleSchedule}
aria-label={i18n._(t`Toggle schedule`)}
diff --git a/awx/ui_next/src/components/Schedule/Schedules.jsx b/awx/ui_next/src/components/Schedule/Schedules.jsx
index b9da804e63..22f429dd29 100644
--- a/awx/ui_next/src/components/Schedule/Schedules.jsx
+++ b/awx/ui_next/src/components/Schedule/Schedules.jsx
@@ -6,28 +6,40 @@ import ScheduleAdd from './ScheduleAdd';
import ScheduleList from './ScheduleList';
function Schedules({
- createSchedule,
+ apiModel,
loadScheduleOptions,
loadSchedules,
setBreadcrumb,
- unifiedJobTemplate,
+ launchConfig,
+ surveyConfig,
+ resource,
}) {
const match = useRouteMatch();
return (
-
+
diff --git a/awx/ui_next/src/components/Schedule/data.schedules.json b/awx/ui_next/src/components/Schedule/data.schedules.json
index 13ef941811..75b1e15ebf 100644
--- a/awx/ui_next/src/components/Schedule/data.schedules.json
+++ b/awx/ui_next/src/components/Schedule/data.schedules.json
@@ -8,6 +8,7 @@
"rrule":
"DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1",
"id": 1,
+ "extra_data":{},
"summary_fields": {
"unified_job_template": {
"id": 6,
@@ -27,6 +28,7 @@
"rrule":
"DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1",
"id": 2,
+ "extra_data":{},
"summary_fields": {
"unified_job_template": {
"id": 7,
@@ -46,6 +48,7 @@
"rrule":
"DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1",
"id": 3,
+ "extra_data":{},
"summary_fields": {
"unified_job_template": {
"id": 8,
@@ -65,6 +68,7 @@
"rrule":
"DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1",
"id": 4,
+ "extra_data":{},
"summary_fields": {
"unified_job_template": {
"id": 9,
@@ -84,6 +88,7 @@
"rrule":
"DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1",
"id": 5,
+ "extra_data":{"novalue":null},
"summary_fields": {
"unified_job_template": {
"id": 10,
@@ -103,4 +108,4 @@
"next_run": "2020-02-20T05:00:00Z"
}
]
-}
\ No newline at end of file
+}
diff --git a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx
index 756e400258..3088996a3d 100644
--- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx
+++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx
@@ -1,22 +1,32 @@
-import React, { useEffect, useCallback } from 'react';
+import React, { useEffect, useCallback, useState } from 'react';
import { shape, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Formik, useField } from 'formik';
import { RRule } from 'rrule';
-import { Form, FormGroup, Title } from '@patternfly/react-core';
+import {
+ Button,
+ Form,
+ FormGroup,
+ Title,
+ ActionGroup,
+} from '@patternfly/react-core';
import { Config } from '../../../contexts/Config';
import { SchedulesAPI } from '../../../api';
import AnsibleSelect from '../../AnsibleSelect';
import ContentError from '../../ContentError';
import ContentLoading from '../../ContentLoading';
-import FormActionGroup from '../../FormActionGroup/FormActionGroup';
import FormField, { FormSubmitError } from '../../FormField';
-import { FormColumnLayout, SubFormLayout } from '../../FormLayout';
+import {
+ FormColumnLayout,
+ SubFormLayout,
+ FormFullWidthLayout,
+} from '../../FormLayout';
import { dateToInputDateTime, formatDateStringUTC } from '../../../util/dates';
import useRequest from '../../../util/useRequest';
import { required } from '../../../util/validators';
import FrequencyDetailSubform from './FrequencyDetailSubform';
+import SchedulePromptableFields from './SchedulePromptableFields';
const generateRunOnTheDay = (days = []) => {
if (
@@ -179,8 +189,14 @@ function ScheduleForm({
i18n,
schedule,
submitError,
+ resource,
+ launchConfig,
+ surveyConfig,
...rest
}) {
+ const [isWizardOpen, setIsWizardOpen] = useState(false);
+ const [isSaveDisabled, setIsSaveDisabled] = useState(false);
+
let rruleError;
const now = new Date();
const closestQuarterHour = new Date(
@@ -189,6 +205,113 @@ function ScheduleForm({
const tomorrow = new Date(closestQuarterHour);
tomorrow.setDate(tomorrow.getDate() + 1);
+ const isTemplate =
+ resource.type === 'workflow_job_template' ||
+ resource.type === 'job_template';
+ const {
+ request: loadScheduleData,
+ error: contentError,
+ contentLoading,
+ result: { zoneOptions, credentials },
+ } = useRequest(
+ useCallback(async () => {
+ const { data } = await SchedulesAPI.readZoneInfo();
+
+ let creds;
+ if (schedule.id) {
+ const {
+ data: { results },
+ } = await SchedulesAPI.readCredentials(schedule.id);
+ creds = results;
+ }
+
+ const zones = data.map(zone => {
+ return {
+ value: zone.name,
+ key: zone.name,
+ label: zone.name,
+ };
+ });
+
+ return {
+ zoneOptions: zones,
+ credentials: creds || [],
+ };
+ }, [schedule]),
+ {
+ zonesOptions: [],
+ credentials: [],
+ }
+ );
+ const missingRequiredInventory = useCallback(() => {
+ let missingInventory = false;
+ if (
+ launchConfig.inventory_needed_to_start &&
+ !schedule?.summary_fields?.inventory?.id
+ ) {
+ missingInventory = true;
+ }
+ return missingInventory;
+ }, [launchConfig, schedule]);
+
+ const hasMissingSurveyValue = useCallback(() => {
+ let missingValues = false;
+ if (launchConfig?.survey_enabled) {
+ surveyConfig.spec.forEach(question => {
+ const hasDefaultValue = Boolean(question.default);
+ const hasSchedule = Object.keys(schedule).length;
+ const isRequired = question.required;
+ if (isRequired && !hasDefaultValue) {
+ if (!hasSchedule) {
+ missingValues = true;
+ } else {
+ const hasMatchingKey = Object.keys(schedule?.extra_data).includes(
+ question.variable
+ );
+ Object.values(schedule?.extra_data).forEach(value => {
+ if (!value || !hasMatchingKey) {
+ missingValues = true;
+ } else {
+ missingValues = false;
+ }
+ });
+ if (!Object.values(schedule.extra_data).length) {
+ missingValues = true;
+ }
+ }
+ }
+ });
+ }
+ return missingValues;
+ }, [launchConfig, schedule, surveyConfig]);
+
+ useEffect(() => {
+ if (isTemplate && (missingRequiredInventory() || hasMissingSurveyValue())) {
+ setIsSaveDisabled(true);
+ }
+ }, [isTemplate, hasMissingSurveyValue, missingRequiredInventory]);
+
+ useEffect(() => {
+ loadScheduleData();
+ }, [loadScheduleData]);
+
+ let showPromptButton = false;
+
+ if (
+ launchConfig &&
+ (launchConfig.ask_inventory_on_launch ||
+ launchConfig.ask_variables_on_launch ||
+ launchConfig.ask_job_type_on_launch ||
+ launchConfig.ask_limit_on_launch ||
+ launchConfig.ask_credential_on_launch ||
+ launchConfig.ask_scm_branch_on_launch ||
+ launchConfig.survey_enabled ||
+ launchConfig.inventory_needed_to_start ||
+ launchConfig.variables_needed_to_start?.length > 0)
+ ) {
+ showPromptButton = true;
+ }
+
const initialValues = {
daysOfWeek: [],
description: schedule.description || '',
@@ -207,6 +330,19 @@ function ScheduleForm({
startDateTime: dateToInputDateTime(closestQuarterHour),
timezone: schedule.timezone || 'America/New_York',
};
+ const submitSchedule = (
+ values,
+ launchConfiguration,
+ surveyConfiguration,
+ scheduleCredentials
+ ) => {
+ handleSubmit(
+ values,
+ launchConfiguration,
+ surveyConfiguration,
+ scheduleCredentials
+ );
+ };
const overriddenValues = {};
@@ -297,28 +433,6 @@ function ScheduleForm({
}
}
- const {
- request: loadZoneInfo,
- error: contentError,
- contentLoading,
- result: zoneOptions,
- } = useRequest(
- useCallback(async () => {
- const { data } = await SchedulesAPI.readZoneInfo();
- return data.map(zone => {
- return {
- value: zone.name,
- key: zone.name,
- label: zone.name,
- };
- });
- }, [])
- );
-
- useEffect(() => {
- loadZoneInfo();
- }, [loadZoneInfo]);
-
if (contentError || rruleError) {
return ;
}
@@ -333,7 +447,9 @@ function ScheduleForm({
return (
{
+ submitSchedule(values, launchConfig, surveyConfig, credentials);
+ }}
validate={values => {
const errors = {};
const {
@@ -375,11 +491,56 @@ 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..508169ce3e 100644
--- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx
+++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx
@@ -1,11 +1,53 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
-import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
-import { SchedulesAPI } from '../../../api';
+
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../testUtils/enzymeHelpers';
+import { SchedulesAPI, JobTemplatesAPI, InventoriesAPI } from '../../../api';
import ScheduleForm from './ScheduleForm';
jest.mock('../../../api/models/Schedules');
+jest.mock('../../../api/models/JobTemplates');
+jest.mock('../../../api/models/Inventories');
+const credentials = {
+ data: {
+ results: [
+ { id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' },
+ { id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' },
+ { id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' },
+ { id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' },
+ { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
+ ],
+ },
+};
+const launchData = {
+ data: {
+ can_start_without_user_input: false,
+ passwords_needed_to_start: [],
+ ask_scm_branch_on_launch: false,
+ ask_variables_on_launch: false,
+ ask_tags_on_launch: false,
+ ask_diff_mode_on_launch: false,
+ ask_skip_tags_on_launch: false,
+ ask_job_type_on_launch: false,
+ ask_limit_on_launch: false,
+ ask_verbosity_on_launch: false,
+ ask_inventory_on_launch: true,
+ ask_credential_on_launch: false,
+ survey_enabled: false,
+ variables_needed_to_start: [],
+ credential_needed_to_start: false,
+ inventory_needed_to_start: true,
+ job_template_data: {
+ name: 'Demo Job Template',
+ id: 7,
+ description: '',
+ },
+ },
+};
const mockSchedule = {
rrule:
'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY',
@@ -23,7 +65,7 @@ const mockSchedule = {
name: 'mock schedule',
description: 'test description',
extra_data: {},
- inventory: null,
+ inventory: 1,
scm_branch: null,
job_type: null,
job_tags: null,
@@ -82,7 +124,34 @@ describe('', () => {
);
await act(async () => {
wrapper = mountWithContexts(
-
+
);
});
wrapper.update();
@@ -92,6 +161,9 @@ describe('', () => {
describe('Cancel', () => {
test('should make the appropriate callback', async () => {
const handleCancel = jest.fn();
+ JobTemplatesAPI.readLaunch.mockResolvedValue(launchData);
+
+ SchedulesAPI.readCredentials.mockResolvedValue(credentials);
SchedulesAPI.readZoneInfo.mockResolvedValue({
data: [
{
@@ -101,7 +173,34 @@ describe('', () => {
});
await act(async () => {
wrapper = mountWithContexts(
-
+
);
});
wrapper.update();
@@ -111,6 +210,201 @@ describe('', () => {
expect(handleCancel).toHaveBeenCalledTimes(1);
});
});
+ describe('Prompted Schedule', () => {
+ let promptWrapper;
+ beforeEach(async () => {
+ 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 render disabled save button due to missing required surevy values', () => {
+ expect(
+ promptWrapper.find('Button[aria-label="Save"]').prop('isDisabled')
+ ).toBe(true);
+ });
+
+ test('should update prompt modal data', async () => {
+ InventoriesAPI.read.mockResolvedValue({
+ data: {
+ count: 2,
+ results: [
+ {
+ name: 'Foo',
+ id: 1,
+ url: '',
+ },
+ {
+ name: 'Bar',
+ id: 2,
+ url: '',
+ },
+ ],
+ },
+ });
+ InventoriesAPI.readOptions.mockResolvedValue({
+ data: {
+ related_search_fields: [],
+ actions: {
+ GET: {
+ filterable: true,
+ },
+ },
+ },
+ });
+
+ await act(async () =>
+ promptWrapper.find('Button[aria-label="Prompt"]').prop('onClick')()
+ );
+ promptWrapper.update();
+ expect(
+ promptWrapper
+ .find('WizardNavItem')
+ .at(0)
+ .prop('isCurrent')
+ ).toBe(true);
+ await act(async () => {
+ promptWrapper
+ .find('input[aria-labelledby="check-action-item-1"]')
+ .simulate('change', {
+ target: {
+ checked: true,
+ },
+ });
+ });
+ promptWrapper.update();
+ expect(
+ promptWrapper
+ .find('input[aria-labelledby="check-action-item-1"]')
+ .prop('checked')
+ ).toBe(true);
+ await act(async () =>
+ promptWrapper.find('WizardFooterInternal').prop('onNext')()
+ );
+ promptWrapper.update();
+ expect(
+ promptWrapper
+ .find('WizardNavItem')
+ .at(1)
+ .prop('isCurrent')
+ ).toBe(true);
+ await act(async () =>
+ promptWrapper.find('WizardFooterInternal').prop('onNext')()
+ );
+ promptWrapper.update();
+ expect(promptWrapper.find('Wizard').length).toBe(0);
+ });
+ test('should render prompt button with disabled save button', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ waitForElement(
+ wrapper,
+ 'Button[aria-label="Prompt"]',
+ el => el.length > 0
+ );
+ expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe(
+ true
+ );
+ });
+ });
describe('Add', () => {
beforeAll(async () => {
SchedulesAPI.readZoneInfo.mockResolvedValue({
@@ -120,9 +414,37 @@ describe('', () => {
},
],
});
+
await act(async () => {
wrapper = mountWithContexts(
-
+
);
});
});
@@ -312,6 +634,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 +661,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 +699,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 +732,7 @@ describe('', () => {
});
});
describe('Edit', () => {
- beforeAll(async () => {
+ beforeEach(async () => {
SchedulesAPI.readZoneInfo.mockResolvedValue({
data: [
{
@@ -387,10 +740,113 @@ describe('', () => {
},
],
});
+
+ 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(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 +854,8 @@ describe('', () => {
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
schedule={mockSchedule}
+ launchConfig={{ inventory_needed_to_start: false }}
+ resource={{ id: 23, type: 'job_template' }}
/>
);
});
@@ -421,11 +879,13 @@ describe('', () => {
);
});
@@ -453,12 +913,14 @@ describe('', () => {
);
});
@@ -487,12 +949,14 @@ describe('', () => {
);
expect(wrapper.find('ScheduleForm').length).toBe(1);
@@ -520,12 +984,14 @@ describe('', () => {
);
});
@@ -577,12 +1043,14 @@ describe('', () => {
);
expect(wrapper.find('ScheduleForm').length).toBe(1);
@@ -622,12 +1090,14 @@ describe('', () => {
);
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..841aeefa92
--- /dev/null
+++ b/awx/ui_next/src/components/Schedule/shared/useSchedulePromptSteps.js
@@ -0,0 +1,102 @@
+import { useState, useEffect } from 'react';
+import { useFormikContext } from 'formik';
+import { t } from '@lingui/macro';
+import useInventoryStep from '../../LaunchPrompt/steps/useInventoryStep';
+import useCredentialsStep from '../../LaunchPrompt/steps/useCredentialsStep';
+import useOtherPromptsStep from '../../LaunchPrompt/steps/useOtherPromptsStep';
+import useSurveyStep from '../../LaunchPrompt/steps/useSurveyStep';
+import usePreviewStep from '../../LaunchPrompt/steps/usePreviewStep';
+
+export default function useSchedulePromptSteps(
+ surveyConfig,
+ launchConfig,
+ schedule,
+ resource,
+ i18n,
+ scheduleCredentials
+) {
+ const {
+ summary_fields: { credentials: resourceCredentials },
+ } = resource;
+ const sourceOfValues =
+ (Object.keys(schedule).length > 0 && schedule) || resource;
+
+ sourceOfValues.summary_fields = {
+ credentials: [...(resourceCredentials || []), ...scheduleCredentials],
+ ...sourceOfValues.summary_fields,
+ };
+ const { resetForm, values } = useFormikContext();
+ const [visited, setVisited] = useState({});
+
+ const steps = [
+ useInventoryStep(launchConfig, sourceOfValues, i18n, visited),
+ useCredentialsStep(launchConfig, sourceOfValues, i18n),
+ useOtherPromptsStep(launchConfig, sourceOfValues, i18n),
+ useSurveyStep(launchConfig, surveyConfig, sourceOfValues, i18n, visited),
+ ];
+
+ const hasErrors = steps.some(step => step.hasError);
+ steps.push(
+ usePreviewStep(
+ launchConfig,
+ i18n,
+ resource,
+ surveyConfig,
+ hasErrors,
+ true,
+ i18n._(t`Save`)
+ )
+ );
+
+ const pfSteps = steps.map(s => s.step).filter(s => s != null);
+ const isReady = !steps.some(s => !s.isReady);
+
+ useEffect(() => {
+ let initialValues = {};
+ if (launchConfig && surveyConfig && isReady) {
+ initialValues = steps.reduce((acc, cur) => {
+ return {
+ ...acc,
+ ...cur.initialValues,
+ };
+ }, {});
+ }
+ resetForm({
+ values: {
+ ...initialValues,
+ ...values,
+ },
+ });
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [launchConfig, surveyConfig, isReady]);
+
+ const stepWithError = steps.find(s => s.contentError);
+ const contentError = stepWithError ? stepWithError.contentError : null;
+
+ return {
+ isReady,
+ validateStep: stepId => {
+ steps.find(s => s?.step?.id === stepId).validate();
+ },
+ steps: pfSteps,
+ visitStep: (prevStepId, setFieldTouched) => {
+ setVisited({
+ ...visited,
+ [prevStepId]: true,
+ });
+ steps.find(s => s?.step?.id === prevStepId).setTouched(setFieldTouched);
+ },
+ visitAllSteps: setFieldTouched => {
+ setVisited({
+ inventory: true,
+ credentials: true,
+ other: true,
+ survey: true,
+ preview: true,
+ });
+ steps.forEach(s => s.setTouched(setFieldTouched));
+ },
+ contentError,
+ };
+}
diff --git a/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.jsx b/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.jsx
index dec808d20e..a8f56e2aa8 100644
--- a/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.jsx
@@ -69,9 +69,6 @@ function InventorySource({ i18n, inventory, setBreadcrumb, me }) {
[source]
);
- const createSchedule = data =>
- InventorySourcesAPI.createSchedule(source?.id, data);
-
const loadScheduleOptions = useCallback(() => {
return InventorySourcesAPI.readScheduleOptions(source?.id);
}, [source]);
@@ -160,11 +157,11 @@ function InventorySource({ i18n, inventory, setBreadcrumb, me }) {
path="/inventories/inventory/:id/sources/:sourceId/schedules"
>
+ apiModel={InventorySourcesAPI}
+ setBreadcrumb={schedule =>
setBreadcrumb(inventory, source, schedule)
}
- unifiedJobTemplate={source}
+ resource={source}
loadSchedules={loadSchedules}
loadScheduleOptions={loadScheduleOptions}
/>
diff --git a/awx/ui_next/src/screens/Project/Project.jsx b/awx/ui_next/src/screens/Project/Project.jsx
index 72341a5de9..5c3a5a7564 100644
--- a/awx/ui_next/src/screens/Project/Project.jsx
+++ b/awx/ui_next/src/screens/Project/Project.jsx
@@ -78,10 +78,6 @@ function Project({ i18n, setBreadcrumb }) {
}
}, [project, setBreadcrumb]);
- function createSchedule(data) {
- return ProjectsAPI.createSchedule(project.id, data);
- }
-
const loadScheduleOptions = useCallback(() => {
return ProjectsAPI.readScheduleOptions(project.id);
}, [project]);
@@ -188,8 +184,8 @@ function Project({ i18n, setBreadcrumb }) {
diff --git a/awx/ui_next/src/screens/Template/Survey/SurveyListItem.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyListItem.jsx
index 487488cb2e..076c9629d6 100644
--- a/awx/ui_next/src/screens/Template/Survey/SurveyListItem.jsx
+++ b/awx/ui_next/src/screens/Template/Survey/SurveyListItem.jsx
@@ -20,10 +20,12 @@ import DataListCell from '../../../components/DataListCell';
import ChipGroup from '../../../components/ChipGroup';
const DataListAction = styled(_DataListAction)`
- margin-left: 0;
- margin-right: 20px;
- padding-top: 15px;
- padding-bottom: 15px;
+ && {
+ margin-left: 0;
+ margin-right: 20px;
+ padding-top: 0;
+ padding-bottom: 0;
+ }
`;
const Button = styled(_Button)`
padding-top: 0;
diff --git a/awx/ui_next/src/screens/Template/Template.jsx b/awx/ui_next/src/screens/Template/Template.jsx
index 50b238b3b7..4bc2216c22 100644
--- a/awx/ui_next/src/screens/Template/Template.jsx
+++ b/awx/ui_next/src/screens/Template/Template.jsx
@@ -32,20 +32,33 @@ function Template({ i18n, setBreadcrumb }) {
const { me = {} } = useConfig();
const {
- result: { isNotifAdmin, template },
+ result: { isNotifAdmin, template, surveyConfig, launchConfig },
isLoading,
error: contentError,
request: loadTemplateAndRoles,
} = useRequest(
useCallback(async () => {
- const [{ data }, actions, notifAdminRes] = await Promise.all([
+ const [
+ { data },
+ actions,
+ notifAdminRes,
+ { data: launchConfiguration },
+ ] = await Promise.all([
JobTemplatesAPI.readDetail(templateId),
JobTemplatesAPI.readTemplateOptions(templateId),
OrganizationsAPI.read({
page_size: 1,
role_level: 'notification_admin_role',
}),
+ JobTemplatesAPI.readLaunch(templateId),
]);
+ let surveyConfiguration = null;
+
+ if (data.survey_enabled) {
+ const { data: survey } = await JobTemplatesAPI.readSurvey(templateId);
+
+ surveyConfiguration = survey;
+ }
if (data.summary_fields.credentials) {
const params = {
page: 1,
@@ -71,6 +84,8 @@ function Template({ i18n, setBreadcrumb }) {
return {
template: data,
isNotifAdmin: notifAdminRes.data.results.length > 0,
+ surveyConfig: surveyConfiguration,
+ launchConfig: launchConfiguration,
};
}, [templateId]),
{ isNotifAdmin: false, template: null }
@@ -86,10 +101,6 @@ function Template({ i18n, setBreadcrumb }) {
}
}, [template, setBreadcrumb]);
- const createSchedule = data => {
- return JobTemplatesAPI.createSchedule(template.id, data);
- };
-
const loadScheduleOptions = useCallback(() => {
return JobTemplatesAPI.readScheduleOptions(templateId);
}, [templateId]);
@@ -203,11 +214,13 @@ function Template({ i18n, setBreadcrumb }) {
path="/templates/:templateType/:id/schedules"
>
{canSeeNotificationsTab && (
diff --git a/awx/ui_next/src/screens/Template/Template.test.jsx b/awx/ui_next/src/screens/Template/Template.test.jsx
index afb7221f06..d0e370a697 100644
--- a/awx/ui_next/src/screens/Template/Template.test.jsx
+++ b/awx/ui_next/src/screens/Template/Template.test.jsx
@@ -21,7 +21,7 @@ describe('', () => {
let wrapper;
beforeEach(() => {
JobTemplatesAPI.readDetail.mockResolvedValue({
- data: mockJobTemplateData,
+ data: { ...mockJobTemplateData, survey_enabled: false },
});
JobTemplatesAPI.readTemplateOptions.mockResolvedValue({
data: {
@@ -56,6 +56,7 @@ describe('', () => {
],
},
});
+ JobTemplatesAPI.readLaunch.mockResolvedValue({ data: {} });
JobTemplatesAPI.readWebhookKey.mockResolvedValue({
data: {
webhook_key: 'key',
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx
index ee22010983..6bfa39c6c8 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx
@@ -36,21 +36,37 @@ function WorkflowJobTemplate({ i18n, setBreadcrumb }) {
const { me = {} } = useConfig();
const {
- result: { isNotifAdmin, template },
+ result: { isNotifAdmin, template, surveyConfig, launchConfig },
isLoading: hasRolesandTemplateLoading,
error: rolesAndTemplateError,
request: loadTemplateAndRoles,
} = useRequest(
useCallback(async () => {
- const [{ data }, actions, notifAdminRes] = await Promise.all([
+ const [
+ { data },
+ actions,
+ notifAdminRes,
+ { data: launchConfiguration },
+ ] = await Promise.all([
WorkflowJobTemplatesAPI.readDetail(templateId),
WorkflowJobTemplatesAPI.readWorkflowJobTemplateOptions(templateId),
OrganizationsAPI.read({
page_size: 1,
role_level: 'notification_admin_role',
}),
+ WorkflowJobTemplatesAPI.readLaunch(templateId),
]);
+ let surveyConfiguration = null;
+
+ if (data.survey_enabled) {
+ const { data: survey } = await WorkflowJobTemplatesAPI.readSurvey(
+ templateId
+ );
+
+ surveyConfiguration = survey;
+ }
+
if (actions.data.actions.PUT) {
if (data.webhook_service && data?.related?.webhook_key) {
const {
@@ -65,6 +81,8 @@ function WorkflowJobTemplate({ i18n, setBreadcrumb }) {
return {
template: data,
isNotifAdmin: notifAdminRes.data.results.length > 0,
+ launchConfig: launchConfiguration,
+ surveyConfig: surveyConfiguration,
};
}, [setBreadcrumb, templateId]),
{ isNotifAdmin: false, template: null }
@@ -73,10 +91,6 @@ function WorkflowJobTemplate({ i18n, setBreadcrumb }) {
loadTemplateAndRoles();
}, [loadTemplateAndRoles, location.pathname]);
- const createSchedule = data => {
- return WorkflowJobTemplatesAPI.createSchedule(templateId, data);
- };
-
const loadScheduleOptions = useCallback(() => {
return WorkflowJobTemplatesAPI.readScheduleOptions(templateId);
}, [templateId]);
@@ -206,11 +220,13 @@ function WorkflowJobTemplate({ i18n, setBreadcrumb }) {
path="/templates/:templateType/:id/schedules"
>
)}
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.test.jsx
index 5694764058..17febc6af1 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.test.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.test.jsx
@@ -26,7 +26,7 @@ describe('', () => {
let wrapper;
beforeEach(() => {
WorkflowJobTemplatesAPI.readDetail.mockResolvedValue({
- data: mockWorkflowJobTemplateData,
+ data: { ...mockWorkflowJobTemplateData, survey_enabled: false },
});
WorkflowJobTemplatesAPI.readWorkflowJobTemplateOptions.mockResolvedValue({
data: {
@@ -45,6 +45,7 @@ describe('', () => {
],
},
});
+ WorkflowJobTemplatesAPI.readLaunch.mockResolvedValue({ data: {} });
WorkflowJobTemplatesAPI.readWebhookKey.mockResolvedValue({
data: {
webhook_key: 'key',