From 017064aecfee5d074d482ef9598924bbe63aac6f Mon Sep 17 00:00:00 2001 From: mabashian Date: Fri, 3 Apr 2020 10:41:19 -0400 Subject: [PATCH] Adds support for editing proj/jt/wfjt schedule --- .../src/scheduler/schedulerEdit.controller.js | 3 + .../components/FormField/FormSubmitError.jsx | 23 +- .../src/components/Schedule/Schedule.jsx | 7 +- .../Schedule/ScheduleAdd/ScheduleAdd.jsx | 101 +---- .../Schedule/ScheduleAdd/ScheduleAdd.test.jsx | 6 +- .../Schedule/ScheduleEdit/ScheduleEdit.jsx | 58 +++ .../ScheduleEdit/ScheduleEdit.test.jsx | 285 ++++++++++++++ .../components/Schedule/ScheduleEdit/index.js | 1 + awx/ui_next/src/components/Schedule/index.js | 1 + .../shared/FrequencyDetailSubform.jsx | 29 +- .../Schedule/shared/ScheduleForm.jsx | 186 +++++++-- .../Schedule/shared/ScheduleForm.test.jsx | 368 +++++++++++++++++- .../Schedule/shared/buildRuleObj.js | 101 +++++ awx/ui_next/src/screens/Project/Projects.jsx | 3 +- .../src/screens/Template/Templates.jsx | 2 + 15 files changed, 1015 insertions(+), 159 deletions(-) create mode 100644 awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx create mode 100644 awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx create mode 100644 awx/ui_next/src/components/Schedule/ScheduleEdit/index.js create mode 100644 awx/ui_next/src/components/Schedule/shared/buildRuleObj.js diff --git a/awx/ui/client/src/scheduler/schedulerEdit.controller.js b/awx/ui/client/src/scheduler/schedulerEdit.controller.js index d0cd7e1090..0a866fe10e 100644 --- a/awx/ui/client/src/scheduler/schedulerEdit.controller.js +++ b/awx/ui/client/src/scheduler/schedulerEdit.controller.js @@ -8,6 +8,8 @@ function($filter, $state, $stateParams, Wait, $scope, moment, WorkflowJobTemplate, SchedulerStrings, scheduleResolve, timezonesResolve, Alert ) { + console.log(scheduleResolve); + let schedule, scheduler, scheduleCredentials = []; /* @@ -161,6 +163,7 @@ function($filter, $state, $stateParams, Wait, $scope, moment, function setUntil (scheduler) { let { until } = scheduleResolve; if(until !== ''){ + console.log(until); const date = moment(until); const endDt = moment.parseZone(date).format("MM/DD/YYYY"); const endHour = date.format('HH'); diff --git a/awx/ui_next/src/components/FormField/FormSubmitError.jsx b/awx/ui_next/src/components/FormField/FormSubmitError.jsx index 1c90e11da5..7b6edc6cb3 100644 --- a/awx/ui_next/src/components/FormField/FormSubmitError.jsx +++ b/awx/ui_next/src/components/FormField/FormSubmitError.jsx @@ -10,18 +10,21 @@ function FormSubmitError({ error }) { if (!error) { return; } - if (error?.response?.data && typeof error.response.data === 'object') { + if ( + error?.response?.data && + typeof error.response.data === 'object' && + Object.keys(error.response.data).length > 0 + ) { const errorMessages = error.response.data; setErrors(errorMessages); - if (errorMessages.__all__) { - setErrorMessage(errorMessages.__all__); - } else if (errorMessages.detail) { - setErrorMessage(errorMessages.detail); - } else if (errorMessages.resources_needed_to_start) { - setErrorMessage(errorMessages.resources_needed_to_start); - } else { - setErrorMessage(null); - } + + let messages = []; + Object.values(error.response.data).forEach(value => { + if (Array.isArray(value)) { + messages = messages.concat(value); + } + }); + setErrorMessage(messages.length > 0 ? messages : null); } else { /* eslint-disable-next-line no-console */ console.error(error); diff --git a/awx/ui_next/src/components/Schedule/Schedule.jsx b/awx/ui_next/src/components/Schedule/Schedule.jsx index 7efd41e4ea..97dfc2a436 100644 --- a/awx/ui_next/src/components/Schedule/Schedule.jsx +++ b/awx/ui_next/src/components/Schedule/Schedule.jsx @@ -17,7 +17,7 @@ import RoutedTabs from '@components/RoutedTabs'; import ContentError from '@components/ContentError'; import ContentLoading from '@components/ContentLoading'; import { TabbedCardHeader } from '@components/Card'; -import { ScheduleDetail } from '@components/Schedule'; +import { ScheduleDetail, ScheduleEdit } from '@components/Schedule'; import { SchedulesAPI } from '@api'; function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) { @@ -108,6 +108,11 @@ function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) { exact /> {schedule && [ + } + />, { - const [startDate, startTime] = values.startDateTime.split('T'); - // Dates are formatted like "YYYY-MM-DD" - const [startYear, startMonth, startDay] = startDate.split('-'); - // Times are formatted like "HH:MM:SS" or "HH:MM" if no seconds - // have been specified - const [startHour = 0, startMinute = 0, startSecond = 0] = startTime.split( - ':' - ); - - const ruleObj = { - interval: values.interval, - dtstart: new Date( - Date.UTC( - startYear, - parseInt(startMonth, 10) - 1, - startDay, - startHour, - startMinute, - startSecond - ) - ), - tzid: values.timezone, - }; - - switch (values.frequency) { - case 'none': - ruleObj.count = 1; - ruleObj.freq = RRule.MINUTELY; - break; - case 'minute': - ruleObj.freq = RRule.MINUTELY; - break; - case 'hour': - ruleObj.freq = RRule.HOURLY; - break; - case 'day': - ruleObj.freq = RRule.DAILY; - break; - case 'week': - ruleObj.freq = RRule.WEEKLY; - ruleObj.byweekday = values.daysOfWeek.map(day => RRule[day]); - break; - case 'month': - ruleObj.freq = RRule.MONTHLY; - if (values.runOn === 'day') { - ruleObj.bymonthday = values.runOnDayNumber; - } else if (values.runOn === 'the') { - ruleObj.bysetpos = parseInt(values.runOnTheOccurrence, 10); - ruleObj.byweekday = getRRuleDayConstants(values.runOnTheDay, i18n); - } - break; - case 'year': - ruleObj.freq = RRule.YEARLY; - if (values.runOn === 'day') { - ruleObj.bymonth = parseInt(values.runOnDayMonth, 10); - ruleObj.bymonthday = values.runOnDayNumber; - } else if (values.runOn === 'the') { - ruleObj.bysetpos = parseInt(values.runOnTheOccurrence, 10); - ruleObj.byweekday = getRRuleDayConstants(values.runOnTheDay, i18n); - ruleObj.bymonth = parseInt(values.runOnTheMonth, 10); - } - break; - default: - throw new Error(i18n._(t`Frequency did not match an expected value`)); - } - - switch (values.end) { - case 'never': - break; - case 'after': - ruleObj.count = values.occurrences; - break; - case 'onDate': { - const [endDate, endTime] = values.endDateTime.split('T'); - const [endYear, endMonth, endDay] = endDate.split('-'); - const [endHour = 0, endMinute = 0, endSecond = 0] = endTime.split(':'); - ruleObj.until = new Date( - Date.UTC( - endYear, - parseInt(endMonth, 10) - 1, - endDay, - endHour, - endMinute, - endSecond - ) - ); - break; - } - default: - throw new Error(i18n._(t`End did not match an expected value`)); - } - - return ruleObj; - }; - const handleSubmit = async values => { try { - const rule = new RRule(buildRuleObj(values)); + const rule = new RRule(buildRuleObj(values, i18n)); const { data: { id: scheduleId }, } = await createSchedule({ 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 8a3376e504..0438a01497 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { RRule } from 'rrule'; import { SchedulesAPI } from '@api'; import ScheduleAdd from './ScheduleAdd'; @@ -117,7 +118,7 @@ describe('', () => { test('Successfully creates a schedule with weekly repeat frequency on mon/wed/fri', async () => { await act(async () => { wrapper.find('ScheduleForm').invoke('handleSubmit')({ - daysOfWeek: ['MO', 'WE', 'FR'], + daysOfWeek: [RRule.MO, RRule.WE, RRule.FR], description: 'test description', end: 'never', frequency: 'week', @@ -131,8 +132,7 @@ describe('', () => { expect(createSchedule).toHaveBeenCalledWith({ description: 'test description', name: 'Run weekly on mon/wed/fri', - rrule: - 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=MO,WE,FR', + 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 () => { diff --git a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx new file mode 100644 index 0000000000..74e3a652f6 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.jsx @@ -0,0 +1,58 @@ +import React, { useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { useHistory, useLocation } from 'react-router-dom'; +import { RRule } from 'rrule'; +import { object } from 'prop-types'; +import { Card } from '@patternfly/react-core'; +import { CardBody } from '@components/Card'; +import { SchedulesAPI } from '@api'; +import buildRuleObj from '../shared/buildRuleObj'; +import ScheduleForm from '../shared/ScheduleForm'; + +function ScheduleEdit({ i18n, schedule }) { + const [formSubmitError, setFormSubmitError] = useState(null); + const history = useHistory(); + const location = useLocation(); + const { pathname } = location; + const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); + + const handleSubmit = async values => { + try { + const rule = new RRule(buildRuleObj(values, i18n)); + const { + data: { id: scheduleId }, + } = await SchedulesAPI.update(schedule.id, { + name: values.name, + description: values.description, + rrule: rule.toString().replace(/\n/g, ' '), + }); + + history.push(`${pathRoot}schedules/${scheduleId}/details`); + } catch (err) { + setFormSubmitError(err); + } + }; + + return ( + + + + history.push(`${pathRoot}schedules/${schedule.id}/details`) + } + handleSubmit={handleSubmit} + submitError={formSubmitError} + /> + + + ); +} + +ScheduleEdit.propTypes = { + schedule: object.isRequired, +}; + +ScheduleEdit.defaultProps = {}; + +export default withI18n()(ScheduleEdit); diff --git a/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx new file mode 100644 index 0000000000..21c7b700b6 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.jsx @@ -0,0 +1,285 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { RRule } from 'rrule'; +import { SchedulesAPI } from '@api'; +import ScheduleEdit from './ScheduleEdit'; + +jest.mock('@api/models/Schedules'); + +SchedulesAPI.readZoneInfo.mockResolvedValue({ + data: [ + { + name: 'America/New_York', + }, + ], +}); + +SchedulesAPI.update.mockResolvedValue({ + data: { + id: 27, + }, +}); + +let wrapper; + +const mockSchedule = { + rrule: + 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', + id: 27, + type: 'schedule', + url: '/api/v2/schedules/27/', + summary_fields: { + user_capabilities: { + edit: true, + delete: true, + }, + }, + created: '2020-04-02T18:43:12.664142Z', + modified: '2020-04-02T18:43:12.664185Z', + name: 'mock schedule', + description: '', + extra_data: {}, + inventory: null, + scm_branch: null, + job_type: null, + job_tags: null, + skip_tags: null, + limit: null, + diff_mode: null, + verbosity: null, + unified_job_template: 11, + enabled: true, + dtstart: '2020-04-02T18:45:00Z', + dtend: '2020-04-02T18:45:00Z', + next_run: '2020-04-02T18:45:00Z', + timezone: 'America/New_York', + until: '', +}; + +describe('', () => { + beforeAll(async () => { + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + test('Successfully creates a schedule with repeat frequency: None (run once)', async () => { + await act(async () => { + wrapper.find('ScheduleForm').invoke('handleSubmit')({ + description: 'test description', + end: 'never', + frequency: 'none', + interval: 1, + name: 'Run once schedule', + startDateTime: '2020-03-25T10:00:00', + timezone: 'America/New_York', + }); + }); + expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { + description: 'test description', + name: 'Run once schedule', + 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')({ + description: 'test description', + end: 'after', + frequency: 'minute', + interval: 10, + name: 'Run every 10 minutes 10 times', + occurrences: 10, + startDateTime: '2020-03-25T10:30:00', + timezone: 'America/New_York', + }); + }); + expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { + description: 'test description', + name: 'Run every 10 minutes 10 times', + 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')({ + description: 'test description', + end: 'onDate', + endDateTime: '2020-03-26T10:45:00', + frequency: 'hour', + interval: 1, + name: 'Run every hour until date', + startDateTime: '2020-03-25T10:45:00', + timezone: 'America/New_York', + }); + }); + expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { + description: 'test description', + name: 'Run every hour until date', + 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')({ + description: 'test description', + end: 'never', + frequency: 'day', + interval: 1, + name: 'Run daily', + startDateTime: '2020-03-25T10:45:00', + timezone: 'America/New_York', + }); + }); + expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { + description: 'test description', + name: 'Run daily', + 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')({ + daysOfWeek: [RRule.MO, RRule.WE, RRule.FR], + description: 'test description', + end: 'never', + frequency: 'week', + interval: 1, + name: 'Run weekly on mon/wed/fri', + occurrences: 1, + startDateTime: '2020-03-25T10:45:00', + timezone: 'America/New_York', + }); + }); + expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { + description: 'test description', + name: 'Run weekly on mon/wed/fri', + 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')({ + description: 'test description', + end: 'never', + frequency: 'month', + interval: 1, + name: 'Run on the first day of the month', + occurrences: 1, + runOn: 'day', + runOnDayNumber: 1, + startDateTime: '2020-04-01T10:45', + timezone: 'America/New_York', + }); + }); + expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { + description: 'test description', + name: 'Run on the first day of the month', + 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')({ + description: 'test description', + end: 'never', + endDateTime: '2020-03-26T11:00:00', + frequency: 'month', + interval: 1, + name: 'Run monthly on the last Tuesday', + occurrences: 1, + runOn: 'the', + runOnTheDay: 'tuesday', + runOnTheOccurrence: -1, + startDateTime: '2020-03-31T11:00', + timezone: 'America/New_York', + }); + }); + expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { + description: 'test description', + name: 'Run monthly on the last Tuesday', + 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')({ + description: 'test description', + end: 'never', + frequency: 'year', + interval: 1, + name: 'Yearly on the first day of March', + occurrences: 1, + runOn: 'day', + runOnDayMonth: 3, + runOnDayNumber: 1, + startDateTime: '2020-03-01T00:00', + timezone: 'America/New_York', + }); + }); + expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { + description: 'test description', + name: 'Yearly on the first day of March', + 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')({ + description: 'test description', + end: 'never', + frequency: 'year', + interval: 1, + name: 'Yearly on the second Friday in April', + occurrences: 1, + runOn: 'the', + runOnTheOccurrence: 2, + runOnTheDay: 'friday', + runOnTheMonth: 4, + startDateTime: '2020-04-10T11:15', + timezone: 'America/New_York', + }); + }); + expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { + description: 'test description', + name: 'Yearly on the second Friday in April', + 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')({ + description: 'test description', + end: 'never', + frequency: 'year', + interval: 1, + name: 'Yearly on the first weekday in October', + occurrences: 1, + runOn: 'the', + runOnTheOccurrence: 1, + runOnTheDay: 'weekday', + runOnTheMonth: 10, + startDateTime: '2020-04-10T11:15', + timezone: 'America/New_York', + }); + }); + expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { + description: 'test description', + name: 'Yearly on the first weekday in October', + rrule: + 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=1;BYDAY=MO,TU,WE,TH,FR;BYMONTH=10', + }); + }); +}); diff --git a/awx/ui_next/src/components/Schedule/ScheduleEdit/index.js b/awx/ui_next/src/components/Schedule/ScheduleEdit/index.js new file mode 100644 index 0000000000..2e2e554f1f --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleEdit/index.js @@ -0,0 +1 @@ +export { default } from './ScheduleEdit'; diff --git a/awx/ui_next/src/components/Schedule/index.js b/awx/ui_next/src/components/Schedule/index.js index f70d8ef3fd..5342b7ae20 100644 --- a/awx/ui_next/src/components/Schedule/index.js +++ b/awx/ui_next/src/components/Schedule/index.js @@ -5,3 +5,4 @@ export { default as ScheduleOccurrences } from './ScheduleOccurrences'; export { default as ScheduleToggle } from './ScheduleToggle'; export { default as ScheduleDetail } from './ScheduleDetail'; export { default as ScheduleAdd } from './ScheduleAdd'; +export { default as ScheduleEdit } from './ScheduleEdit'; diff --git a/awx/ui_next/src/components/Schedule/shared/FrequencyDetailSubform.jsx b/awx/ui_next/src/components/Schedule/shared/FrequencyDetailSubform.jsx index 2ca554b8dd..c062258354 100644 --- a/awx/ui_next/src/components/Schedule/shared/FrequencyDetailSubform.jsx +++ b/awx/ui_next/src/components/Schedule/shared/FrequencyDetailSubform.jsx @@ -3,6 +3,7 @@ import styled from 'styled-components'; import { useField } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { RRule } from 'rrule'; import { Checkbox as _Checkbox, FormGroup, @@ -255,9 +256,9 @@ const FrequencyDetailSubform = ({ i18n }) => {
{ - updateDaysOfWeek('SU', checked); + updateDaysOfWeek(RRule.SU, checked); }} aria-label={i18n._(t`Sunday`)} id="schedule-days-of-week-sun" @@ -265,9 +266,9 @@ const FrequencyDetailSubform = ({ i18n }) => { /> { - updateDaysOfWeek('MO', checked); + updateDaysOfWeek(RRule.MO, checked); }} aria-label={i18n._(t`Monday`)} id="schedule-days-of-week-mon" @@ -275,9 +276,9 @@ const FrequencyDetailSubform = ({ i18n }) => { /> { - updateDaysOfWeek('TU', checked); + updateDaysOfWeek(RRule.TU, checked); }} aria-label={i18n._(t`Tuesday`)} id="schedule-days-of-week-tue" @@ -285,9 +286,9 @@ const FrequencyDetailSubform = ({ i18n }) => { /> { - updateDaysOfWeek('WE', checked); + updateDaysOfWeek(RRule.WE, checked); }} aria-label={i18n._(t`Wednesday`)} id="schedule-days-of-week-wed" @@ -295,9 +296,9 @@ const FrequencyDetailSubform = ({ i18n }) => { /> { - updateDaysOfWeek('TH', checked); + updateDaysOfWeek(RRule.TH, checked); }} aria-label={i18n._(t`Thursday`)} id="schedule-days-of-week-thu" @@ -305,9 +306,9 @@ const FrequencyDetailSubform = ({ i18n }) => { /> { - updateDaysOfWeek('FR', checked); + updateDaysOfWeek(RRule.FR, checked); }} aria-label={i18n._(t`Friday`)} id="schedule-days-of-week-fri" @@ -315,9 +316,9 @@ const FrequencyDetailSubform = ({ i18n }) => { /> { - updateDaysOfWeek('SA', checked); + updateDaysOfWeek(RRule.SA, checked); }} aria-label={i18n._(t`Saturday`)} id="schedule-days-of-week-sat" diff --git a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx index 41f43da561..ddb00ccbbf 100644 --- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx @@ -3,6 +3,7 @@ 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 { Config } from '@contexts/Config'; import { Form, FormGroup, Title } from '@patternfly/react-core'; import { SchedulesAPI } from '@api'; @@ -12,11 +13,60 @@ import ContentLoading from '@components/ContentLoading'; import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; import FormField, { FormSubmitError } from '@components/FormField'; import { FormColumnLayout, SubFormLayout } from '@components/FormLayout'; -import { dateToInputDateTime } from '@util/dates'; +import { dateToInputDateTime, formatDateStringUTC } from '@util/dates'; import useRequest from '@util/useRequest'; import { required } from '@util/validators'; import FrequencyDetailSubform from './FrequencyDetailSubform'; +const generateRunOnTheDay = (days = []) => { + if ( + [ + RRule.MO, + RRule.TU, + RRule.WE, + RRule.TH, + RRule.FR, + RRule.SA, + RRule.SU, + ].every(element => days.indexOf(element) > -1) + ) { + return 'day'; + } + if ( + [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR].every( + element => days.indexOf(element) > -1 + ) + ) { + return 'weekday'; + } + if ([RRule.SA, RRule.SU].every(element => days.indexOf(element) > -1)) { + return 'weekendDay'; + } + if (days.indexOf(RRule.MO) > -1) { + return 'monday'; + } + if (days.indexOf(RRule.TU) > -1) { + return 'tuesday'; + } + if (days.indexOf(RRule.WE) > -1) { + return 'wednesday'; + } + if (days.indexOf(RRule.TH) > -1) { + return 'thursday'; + } + if (days.indexOf(RRule.FR) > -1) { + return 'friday'; + } + if (days.indexOf(RRule.SA) > -1) { + return 'saturday'; + } + if (days.indexOf(RRule.SU) > -1) { + return 'sunday'; + } + + return null; +}; + function ScheduleFormFields({ i18n, zoneOptions }) { const [startDateTime, startDateTimeMeta] = useField({ name: 'startDateTime', @@ -121,6 +171,7 @@ function ScheduleForm({ submitError, ...rest }) { + let rruleError; const now = new Date(); const closestQuarterHour = new Date( Math.ceil(now.getTime() / 900000) * 900000 @@ -128,6 +179,114 @@ function ScheduleForm({ const tomorrow = new Date(closestQuarterHour); tomorrow.setDate(tomorrow.getDate() + 1); + const initialValues = { + daysOfWeek: [], + description: schedule.description || '', + end: 'never', + endDateTime: dateToInputDateTime(tomorrow), + frequency: 'none', + interval: 1, + name: schedule.name || '', + occurrences: 1, + runOn: 'day', + runOnDayMonth: 1, + runOnDayNumber: 1, + runOnTheDay: 'sunday', + runOnTheMonth: 1, + runOnTheOccurrence: 1, + startDateTime: dateToInputDateTime(closestQuarterHour), + timezone: schedule.timezone || 'America/New_York', + }; + + const overriddenValues = {}; + + if (Object.keys(schedule).length > 0) { + if (schedule.rrule) { + try { + const { + origOptions: { + bymonth, + bymonthday, + bysetpos, + byweekday, + count, + dtstart, + freq, + interval, + }, + } = RRule.fromString(schedule.rrule.replace(' ', '\n')); + + if (dtstart) { + overriddenValues.startDateTime = dateToInputDateTime( + new Date(formatDateStringUTC(dtstart)) + ); + } + + if (schedule.until) { + overriddenValues.end = 'onDate'; + overriddenValues.endDateTime = schedule.until; + } else if (count) { + overriddenValues.end = 'after'; + overriddenValues.occurrences = count; + } + + if (interval) { + overriddenValues.interval = interval; + } + + if (typeof freq === 'number') { + switch (freq) { + case RRule.MINUTELY: + if (schedule.dtstart !== schedule.dtend) { + overriddenValues.frequency = 'minute'; + } + break; + case RRule.HOURLY: + overriddenValues.frequency = 'hour'; + break; + case RRule.DAILY: + overriddenValues.frequency = 'day'; + break; + case RRule.WEEKLY: + overriddenValues.frequency = 'week'; + if (byweekday) { + overriddenValues.daysOfWeek = byweekday; + } + break; + case RRule.MONTHLY: + overriddenValues.frequency = 'month'; + if (bymonthday) { + overriddenValues.runOnDayNumber = bymonthday; + } else if (bysetpos) { + overriddenValues.runOn = 'the'; + overriddenValues.runOnTheOccurrence = bysetpos; + overriddenValues.runOnTheDay = generateRunOnTheDay(byweekday); + } + break; + case RRule.YEARLY: + overriddenValues.frequency = 'year'; + if (bymonthday) { + overriddenValues.runOnDayNumber = bymonthday; + overriddenValues.runOnDayMonth = bymonth; + } else if (bysetpos) { + overriddenValues.runOn = 'the'; + overriddenValues.runOnTheOccurrence = bysetpos; + overriddenValues.runOnTheDay = generateRunOnTheDay(byweekday); + overriddenValues.runOnTheMonth = bymonth; + } + break; + default: + break; + } + } + } catch (error) { + rruleError = error; + } + } else { + rruleError = new Error(i18n._(t`Schedule is missing rrule`)); + } + } + const { request: loadZoneInfo, error: contentError, @@ -150,8 +309,8 @@ function ScheduleForm({ loadZoneInfo(); }, [loadZoneInfo]); - if (contentError) { - return ; + if (contentError || rruleError) { + return ; } if (contentLoading) { @@ -163,24 +322,7 @@ function ScheduleForm({ {() => { return ( { const errors = {}; @@ -208,7 +350,7 @@ function ScheduleForm({ (runOnDayNumber < 1 || runOnDayNumber > 31) ) { errors.runOn = i18n._( - t`Please select a day number between 1 and 31` + t`Please select a day number between 1 and 31.` ); } 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 2487f9beee..45b802dd0f 100644 --- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx @@ -6,6 +6,40 @@ import ScheduleForm from './ScheduleForm'; jest.mock('@api/models/Schedules'); +const mockSchedule = { + rrule: + 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', + id: 27, + type: 'schedule', + url: '/api/v2/schedules/27/', + summary_fields: { + user_capabilities: { + edit: true, + delete: true, + }, + }, + created: '2020-04-02T18:43:12.664142Z', + modified: '2020-04-02T18:43:12.664185Z', + name: 'mock schedule', + description: 'test description', + extra_data: {}, + inventory: null, + scm_branch: null, + job_type: null, + job_tags: null, + skip_tags: null, + limit: null, + diff_mode: null, + verbosity: null, + unified_job_template: 11, + enabled: true, + dtstart: '2020-04-02T18:45:00Z', + dtend: '2020-04-02T18:45:00Z', + next_run: '2020-04-02T18:45:00Z', + timezone: 'America/New_York', + until: '', +}; + let wrapper; const defaultFieldsVisible = () => { @@ -16,6 +50,21 @@ const defaultFieldsVisible = () => { expect(wrapper.find('FormGroup[label="Run frequency"]').length).toBe(1); }; +const nonRRuleValuesMatch = () => { + expect(wrapper.find('input#schedule-name').prop('value')).toBe( + 'mock schedule' + ); + expect(wrapper.find('input#schedule-description').prop('value')).toBe( + 'test description' + ); + expect(wrapper.find('input#schedule-start-datetime').prop('value')).toBe( + '2020-04-02T14:45:00' + ); + expect(wrapper.find('select#schedule-timezone').prop('value')).toBe( + 'America/New_York' + ); +}; + describe('', () => { describe('Error', () => { test('should display error when error occurs while loading', async () => { @@ -296,20 +345,321 @@ describe('', () => { expect(wrapper.find('input#end-on-date').prop('checked')).toBe(true); expect(wrapper.find('#schedule-end-datetime-helper').length).toBe(0); await act(async () => { - wrapper.find('input#schedule-end-datetime').invoke('onChange')( - '2020-03-14T01:45:00', - { - target: { name: 'endDateTime' }, - } - ); + wrapper.find('input#schedule-end-datetime').simulate('change', { + target: { name: 'endDateTime', value: '2020-03-14T01:45:00' }, + }); }); wrapper.update(); - setTimeout(() => { - expect(wrapper.find('#schedule-end-datetime-helper').text()).toBe( - 'Please select an end date/time that comes after the start date/time.' + await act(async () => { + wrapper.find('input#schedule-end-datetime').simulate('blur'); + }); + wrapper.update(); + + expect(wrapper.find('#schedule-end-datetime-helper').text()).toBe( + 'Please select an end date/time that comes after the start date/time.' + ); + }); + test('error shown when on day number is not between 1 and 31', async () => { + await act(async () => { + wrapper.find('input#schedule-run-on-day-number').simulate('change', { + target: { value: 32, name: 'runOnDayNumber' }, + }); + }); + wrapper.update(); + + await act(async () => { + wrapper.find('button[aria-label="Save"]').simulate('click'); + }); + wrapper.update(); + + expect(wrapper.find('#schedule-run-on-helper').text()).toBe( + 'Please select a day number between 1 and 31.' + ); + }); + }); + describe('Edit', () => { + beforeAll(async () => { + SchedulesAPI.readZoneInfo.mockResolvedValue({ + data: [ + { + name: 'America/New_York', + }, + ], + }); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders expected fields and values with existing schedule that runs once', async () => { + await act(async () => { + wrapper = mountWithContexts( + ); }); + expect(wrapper.find('ScheduleForm').length).toBe(1); + defaultFieldsVisible(); + expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="Run on"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="End"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); + + nonRRuleValuesMatch(); + expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( + 'none' + ); + }); + test('initially renders expected fields and values with existing schedule that runs every 10 minutes', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(wrapper.find('ScheduleForm').length).toBe(1); + defaultFieldsVisible(); + expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="Run on"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); + + nonRRuleValuesMatch(); + expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( + 'minute' + ); + expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(10); + 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); + }); + test('initially renders expected fields and values with existing schedule that runs every hour 10 times', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(wrapper.find('ScheduleForm').length).toBe(1); + defaultFieldsVisible(); + expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="Run on"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); + + nonRRuleValuesMatch(); + expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( + 'hour' + ); + expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); + expect(wrapper.find('input#end-never').prop('checked')).toBe(false); + expect(wrapper.find('input#end-after').prop('checked')).toBe(true); + expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); + expect(wrapper.find('input#schedule-occurrences').prop('value')).toBe(10); + }); + test('initially renders expected fields and values with existing schedule that runs every day', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.find('ScheduleForm').length).toBe(1); + defaultFieldsVisible(); + expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="Run on"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); + + nonRRuleValuesMatch(); + expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( + 'day' + ); + 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); + expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); + }); + }); + test('initially renders expected fields and values with existing schedule that runs every week on m/w/f until Jan 1, 2020', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(wrapper.find('ScheduleForm').length).toBe(1); + defaultFieldsVisible(); + expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="On days"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="Run on"]').length).toBe(0); + + nonRRuleValuesMatch(); + expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( + 'week' + ); + expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); + expect(wrapper.find('input#end-never').prop('checked')).toBe(false); + expect(wrapper.find('input#end-after').prop('checked')).toBe(false); + expect(wrapper.find('input#end-on-date').prop('checked')).toBe(true); + expect( + wrapper.find('input#schedule-days-of-week-sun').prop('checked') + ).toBe(false); + expect( + wrapper.find('input#schedule-days-of-week-mon').prop('checked') + ).toBe(true); + expect( + wrapper.find('input#schedule-days-of-week-tue').prop('checked') + ).toBe(false); + expect( + wrapper.find('input#schedule-days-of-week-wed').prop('checked') + ).toBe(true); + expect( + wrapper.find('input#schedule-days-of-week-thu').prop('checked') + ).toBe(false); + expect( + wrapper.find('input#schedule-days-of-week-fri').prop('checked') + ).toBe(true); + expect( + wrapper.find('input#schedule-days-of-week-sat').prop('checked') + ).toBe(false); + expect(wrapper.find('input#schedule-end-datetime').prop('value')).toBe( + '2021-01-01T00:00:00' + ); + }); + test('initially renders expected fields and values with existing schedule that runs every month on the last weekday', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.find('ScheduleForm').length).toBe(1); + defaultFieldsVisible(); + expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Run on"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); + + nonRRuleValuesMatch(); + expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( + 'month' + ); + 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); + expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); + expect(wrapper.find('input#schedule-run-on-day').prop('checked')).toBe( + false + ); + expect(wrapper.find('input#schedule-run-on-the').prop('checked')).toBe( + true + ); + expect( + wrapper.find('select#schedule-run-on-the-occurrence').prop('value') + ).toBe(-1); + expect( + wrapper.find('select#schedule-run-on-the-day').prop('value') + ).toBe('weekday'); + }); + }); + test('initially renders expected fields and values with existing schedule that runs every year on the May 6', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.find('ScheduleForm').length).toBe(1); + defaultFieldsVisible(); + expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Run on"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); + + nonRRuleValuesMatch(); + expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( + 'year' + ); + 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); + expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); + expect(wrapper.find('input#schedule-run-on-day').prop('checked')).toBe( + true + ); + expect(wrapper.find('input#schedule-run-on-the').prop('checked')).toBe( + false + ); + expect( + wrapper.find('select#schedule-run-on-day-month').prop('value') + ).toBe(5); + expect( + wrapper.find('input#schedule-run-on-day-number').prop('value') + ).toBe(6); + }); }); }); }); diff --git a/awx/ui_next/src/components/Schedule/shared/buildRuleObj.js b/awx/ui_next/src/components/Schedule/shared/buildRuleObj.js new file mode 100644 index 0000000000..38828b60ab --- /dev/null +++ b/awx/ui_next/src/components/Schedule/shared/buildRuleObj.js @@ -0,0 +1,101 @@ +import { t } from '@lingui/macro'; +import { RRule } from 'rrule'; +import { getRRuleDayConstants } from '@util/dates'; + +export default function buildRuleObj(values, i18n) { + const [startDate, startTime] = values.startDateTime.split('T'); + // Dates are formatted like "YYYY-MM-DD" + const [startYear, startMonth, startDay] = startDate.split('-'); + // Times are formatted like "HH:MM:SS" or "HH:MM" if no seconds + // have been specified + const [startHour = 0, startMinute = 0, startSecond = 0] = startTime.split( + ':' + ); + + const ruleObj = { + interval: values.interval, + dtstart: new Date( + Date.UTC( + startYear, + parseInt(startMonth, 10) - 1, + startDay, + startHour, + startMinute, + startSecond + ) + ), + tzid: values.timezone, + }; + + switch (values.frequency) { + case 'none': + ruleObj.count = 1; + ruleObj.freq = RRule.MINUTELY; + break; + case 'minute': + ruleObj.freq = RRule.MINUTELY; + break; + case 'hour': + ruleObj.freq = RRule.HOURLY; + break; + case 'day': + ruleObj.freq = RRule.DAILY; + break; + case 'week': + ruleObj.freq = RRule.WEEKLY; + ruleObj.byweekday = values.daysOfWeek; + break; + case 'month': + ruleObj.freq = RRule.MONTHLY; + if (values.runOn === 'day') { + ruleObj.bymonthday = values.runOnDayNumber; + } else if (values.runOn === 'the') { + ruleObj.bysetpos = parseInt(values.runOnTheOccurrence, 10); + ruleObj.byweekday = getRRuleDayConstants(values.runOnTheDay, i18n); + } + break; + case 'year': + ruleObj.freq = RRule.YEARLY; + if (values.runOn === 'day') { + ruleObj.bymonth = parseInt(values.runOnDayMonth, 10); + ruleObj.bymonthday = values.runOnDayNumber; + } else if (values.runOn === 'the') { + ruleObj.bysetpos = parseInt(values.runOnTheOccurrence, 10); + ruleObj.byweekday = getRRuleDayConstants(values.runOnTheDay, i18n); + ruleObj.bymonth = parseInt(values.runOnTheMonth, 10); + } + break; + default: + throw new Error(i18n._(t`Frequency did not match an expected value`)); + } + + if (values.frequency !== 'none') { + switch (values.end) { + case 'never': + break; + case 'after': + ruleObj.count = values.occurrences; + break; + case 'onDate': { + const [endDate, endTime] = values.endDateTime.split('T'); + const [endYear, endMonth, endDay] = endDate.split('-'); + const [endHour = 0, endMinute = 0, endSecond = 0] = endTime.split(':'); + ruleObj.until = new Date( + Date.UTC( + endYear, + parseInt(endMonth, 10) - 1, + endDay, + endHour, + endMinute, + endSecond + ) + ); + break; + } + default: + throw new Error(i18n._(t`End did not match an expected value`)); + } + } + + return ruleObj; +} diff --git a/awx/ui_next/src/screens/Project/Projects.jsx b/awx/ui_next/src/screens/Project/Projects.jsx index 6dba9ce98e..a5bcddd4f0 100644 --- a/awx/ui_next/src/screens/Project/Projects.jsx +++ b/awx/ui_next/src/screens/Project/Projects.jsx @@ -47,8 +47,9 @@ class Projects extends Component { [`${projectSchedulesPath}/add`]: i18n._(t`Create New Schedule`), [`${projectSchedulesPath}/${nested?.id}`]: `${nested?.name}`, [`${projectSchedulesPath}/${nested?.id}/details`]: i18n._( - t`Edit Details` + t`Schedule Details` ), + [`${projectSchedulesPath}/${nested?.id}/edit`]: i18n._(t`Edit Details`), }; this.setState({ breadcrumbConfig }); diff --git a/awx/ui_next/src/screens/Template/Templates.jsx b/awx/ui_next/src/screens/Template/Templates.jsx index 3ade3ef294..2f2cf5497f 100644 --- a/awx/ui_next/src/screens/Template/Templates.jsx +++ b/awx/ui_next/src/screens/Template/Templates.jsx @@ -69,6 +69,8 @@ class Templates extends Component { schedule.id}`]: `${schedule && schedule.name}`, [`/templates/${template.type}/${template.id}/schedules/${schedule && schedule.id}/details`]: i18n._(t`Schedule Details`), + [`/templates/${template.type}/${template.id}/schedules/${schedule && + schedule.id}/edit`]: i18n._(t`Edit Details`), }; this.setState({ breadcrumbConfig }); };