Adds support for editing proj/jt/wfjt schedule

This commit is contained in:
mabashian 2020-04-03 10:41:19 -04:00
parent 7311ddf722
commit 017064aecf
15 changed files with 1015 additions and 159 deletions

View File

@ -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');

View File

@ -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);

View File

@ -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 && [
<Route
key="edit"
path={`${pathRoot}schedules/:id/edit`}
render={() => <ScheduleEdit schedule={schedule} />}
/>,
<Route
key="details"
path={`${pathRoot}schedules/:scheduleId/details`}

View File

@ -1,12 +1,11 @@
import React, { useState } from 'react';
import { func } from 'prop-types';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { useHistory, useLocation } from 'react-router-dom';
import { RRule } from 'rrule';
import { Card } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import { getRRuleDayConstants } from '@util/dates';
import buildRuleObj from '../shared/buildRuleObj';
import ScheduleForm from '../shared/ScheduleForm';
function ScheduleAdd({ i18n, createSchedule }) {
@ -16,105 +15,9 @@ function ScheduleAdd({ i18n, createSchedule }) {
const { pathname } = location;
const pathRoot = pathname.substr(0, pathname.indexOf('schedules'));
const buildRuleObj = values => {
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({

View File

@ -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('<ScheduleAdd />', () => {
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('<ScheduleAdd />', () => {
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 () => {

View File

@ -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 (
<Card>
<CardBody>
<ScheduleForm
schedule={schedule}
handleCancel={() =>
history.push(`${pathRoot}schedules/${schedule.id}/details`)
}
handleSubmit={handleSubmit}
submitError={formSubmitError}
/>
</CardBody>
</Card>
);
}
ScheduleEdit.propTypes = {
schedule: object.isRequired,
};
ScheduleEdit.defaultProps = {};
export default withI18n()(ScheduleEdit);

View File

@ -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('<ScheduleEdit />', () => {
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(<ScheduleEdit schedule={mockSchedule} />);
});
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',
});
});
});

View File

@ -0,0 +1 @@
export { default } from './ScheduleEdit';

View File

@ -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';

View File

@ -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 }) => {
<div css="display: flex">
<Checkbox
label={i18n._(t`Sun`)}
isChecked={daysOfWeek.value.includes('SU')}
isChecked={daysOfWeek.value.includes(RRule.SU)}
onChange={checked => {
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 }) => {
/>
<Checkbox
label={i18n._(t`Mon`)}
isChecked={daysOfWeek.value.includes('MO')}
isChecked={daysOfWeek.value.includes(RRule.MO)}
onChange={checked => {
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 }) => {
/>
<Checkbox
label={i18n._(t`Tue`)}
isChecked={daysOfWeek.value.includes('TU')}
isChecked={daysOfWeek.value.includes(RRule.TU)}
onChange={checked => {
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 }) => {
/>
<Checkbox
label={i18n._(t`Wed`)}
isChecked={daysOfWeek.value.includes('WE')}
isChecked={daysOfWeek.value.includes(RRule.WE)}
onChange={checked => {
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 }) => {
/>
<Checkbox
label={i18n._(t`Thu`)}
isChecked={daysOfWeek.value.includes('TH')}
isChecked={daysOfWeek.value.includes(RRule.TH)}
onChange={checked => {
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 }) => {
/>
<Checkbox
label={i18n._(t`Fri`)}
isChecked={daysOfWeek.value.includes('FR')}
isChecked={daysOfWeek.value.includes(RRule.FR)}
onChange={checked => {
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 }) => {
/>
<Checkbox
label={i18n._(t`Sat`)}
isChecked={daysOfWeek.value.includes('SA')}
isChecked={daysOfWeek.value.includes(RRule.SA)}
onChange={checked => {
updateDaysOfWeek('SA', checked);
updateDaysOfWeek(RRule.SA, checked);
}}
aria-label={i18n._(t`Saturday`)}
id="schedule-days-of-week-sat"

View File

@ -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 <ContentError error={contentError} />;
if (contentError || rruleError) {
return <ContentError error={contentError || rruleError} />;
}
if (contentLoading) {
@ -163,24 +322,7 @@ function ScheduleForm({
{() => {
return (
<Formik
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',
}}
initialValues={Object.assign(initialValues, overriddenValues)}
onSubmit={handleSubmit}
validate={values => {
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.`
);
}

View File

@ -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('<ScheduleForm />', () => {
describe('Error', () => {
test('should display error when error occurs while loading', async () => {
@ -296,20 +345,321 @@ describe('<ScheduleForm />', () => {
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(
<ScheduleForm
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
schedule={mockSchedule}
/>
);
});
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(
<ScheduleForm
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
schedule={Object.assign(mockSchedule, {
rrule:
'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=10;FREQ=MINUTELY',
dtend: null,
})}
/>
);
});
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(
<ScheduleForm
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
schedule={Object.assign(mockSchedule, {
rrule:
'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;FREQ=HOURLY;COUNT=10',
dtend: '2020-04-03T03:45:00Z',
until: '',
})}
/>
);
});
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(
<ScheduleForm
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
schedule={Object.assign(mockSchedule, {
rrule:
'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;FREQ=DAILY',
dtend: null,
until: '',
})}
/>
);
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(
<ScheduleForm
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
schedule={Object.assign(mockSchedule, {
rrule:
'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20210101T050000Z',
dtend: '2020-10-30T18:45:00Z',
until: '2021-01-01T00:00:00',
})}
/>
);
});
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(
<ScheduleForm
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
schedule={Object.assign(mockSchedule, {
rrule:
'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=MO,TU,WE,TH,FR',
dtend: null,
until: '',
})}
/>
);
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(
<ScheduleForm
handleSubmit={jest.fn()}
handleCancel={jest.fn()}
schedule={Object.assign(mockSchedule, {
rrule:
'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=6',
dtend: null,
until: '',
})}
/>
);
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);
});
});
});
});

View File

@ -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;
}

View File

@ -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 });

View File

@ -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 });
};