@@ -128,7 +128,7 @@ exports[` should render initially 1`] = `
class="pf-c-modal-box__body"
id="pf-modal-0"
>
- Are you sure you want to remove {0} access from {1}? Doing so affects all members of the team.
+ Are you sure you want to remove Member access from The Team? Doing so affects all members of the team.
If you only want to remove access for this particular user, please remove them from the team.
@@ -166,7 +166,7 @@ exports[` should render initially 1`] = `
- Remove {0} Access
+ Remove Team Access
}
@@ -177,7 +177,7 @@ exports[` should render initially 1`] = `
isSmall={true}
onClose={[Function]}
showClose={true}
- title="Remove {0} Access"
+ title="Remove Team Access"
>
should render initially 1`] = `
>
should render initially 1`] = `
- Remove {0} Access
+ Remove Team Access
@@ -247,7 +247,7 @@ exports[` should render initially 1`] = `
class="pf-c-modal-box__body"
id="pf-modal-0"
>
- Are you sure you want to remove {0} access from {1}? Doing so affects all members of the team.
+ Are you sure you want to remove Member access from The Team? Doing so affects all members of the team.
If you only want to remove access for this particular user, please remove them from the team.
@@ -303,7 +303,7 @@ exports[` should render initially 1`] = `
- Remove {0} Access
+ Remove Team Access
}
@@ -315,7 +315,7 @@ exports[` should render initially 1`] = `
isSmall={true}
onClose={[Function]}
showClose={true}
- title="Remove {0} Access"
+ title="Remove Team Access"
>
should render initially 1`] = `
isLarge={false}
isSmall={true}
style={Object {}}
- title="Remove {0} Access"
+ title="Remove Team Access"
>
should render initially 1`] = `
- Remove {0} Access
+ Remove Team Access
@@ -542,7 +542,7 @@ exports[` should render initially 1`] = `
className="pf-c-modal-box__body"
id="pf-modal-0"
>
- Are you sure you want to remove {0} access from {1}? Doing so affects all members of the team.
+ Are you sure you want to remove Member access from The Team? Doing so affects all members of the team.
If you only want to remove access for this particular user, please remove them from the team.
diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx
new file mode 100644
index 0000000000..4cbd20231e
--- /dev/null
+++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx
@@ -0,0 +1,168 @@
+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 { getWeekNumber } from '@util/dates';
+import ScheduleForm from '../shared/ScheduleForm';
+
+const days = {
+ 0: 'SU',
+ 1: 'MO',
+ 2: 'TU',
+ 3: 'WE',
+ 4: 'TH',
+ 5: 'FR',
+ 6: 'SA',
+};
+
+function ScheduleAdd({ i18n, createSchedule }) {
+ 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 [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 === 'number') {
+ ruleObj.bymonthday = startDay;
+ } else if (values.runOn === 'day') {
+ ruleObj.byweekday =
+ RRule[days[new Date(values.startDateTime).getDay()]];
+ ruleObj.bysetpos = getWeekNumber(values.startDateTime);
+ } else if (values.runOn === 'lastDay') {
+ ruleObj.byweekday =
+ RRule[days[new Date(values.startDateTime).getDay()]];
+ ruleObj.bysetpos = -1;
+ }
+ break;
+ case 'year':
+ ruleObj.freq = RRule.YEARLY;
+ ruleObj.bymonth = new Date(values.startDateTime).getMonth() + 1;
+ if (values.runOn === 'number') {
+ ruleObj.bymonthday = startDay;
+ } else if (values.runOn === 'day') {
+ ruleObj.byweekday =
+ RRule[days[new Date(values.startDateTime).getDay()]];
+ ruleObj.bysetpos = getWeekNumber(values.startDateTime);
+ } else if (values.runOn === 'lastDay') {
+ ruleObj.byweekday =
+ RRule[days[new Date(values.startDateTime).getDay()]];
+ ruleObj.bysetpos = -1;
+ }
+ 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`));
+ }
+
+ const rule = new RRule(ruleObj);
+ const {
+ data: { id: scheduleId },
+ } = await createSchedule({
+ name: values.name,
+ description: values.description,
+ rrule: rule.toString().replace(/\n/g, ' '),
+ });
+
+ history.push(`${pathRoot}schedules/${scheduleId}`);
+ } catch (err) {
+ setFormSubmitError(err);
+ }
+ };
+
+ return (
+
+
+ history.push(`${pathRoot}schedules`)}
+ handleSubmit={handleSubmit}
+ submitError={formSubmitError}
+ />
+
+
+ );
+}
+
+ScheduleAdd.propTypes = {
+ createSchedule: func.isRequired,
+};
+
+ScheduleAdd.defaultProps = {};
+
+export default withI18n()(ScheduleAdd);
diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx
new file mode 100644
index 0000000000..50f1232c25
--- /dev/null
+++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx
@@ -0,0 +1,227 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
+import { SchedulesAPI } from '@api';
+import ScheduleAdd from './ScheduleAdd';
+
+jest.mock('@api/models/Schedules');
+
+SchedulesAPI.readZoneInfo.mockResolvedValue({
+ data: [
+ {
+ name: 'America/New_York',
+ },
+ ],
+});
+
+let wrapper;
+
+const createSchedule = jest.fn().mockImplementation(() => {
+ return {
+ data: {
+ id: 1,
+ },
+ };
+});
+
+describe('', () => {
+ beforeAll(async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+ 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(createSchedule).toHaveBeenCalledWith({
+ 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,
+ runOn: 'number',
+ startDateTime: '2020-03-25T10:30:00',
+ timezone: 'America/New_York',
+ });
+ });
+ expect(createSchedule).toHaveBeenCalledWith({
+ 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',
+ runOn: 'number',
+ startDateTime: '2020-03-25T10:45:00',
+ timezone: 'America/New_York',
+ });
+ });
+ expect(createSchedule).toHaveBeenCalledWith({
+ 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',
+ runOn: 'number',
+ startDateTime: '2020-03-25T10:45:00',
+ timezone: 'America/New_York',
+ });
+ });
+ expect(createSchedule).toHaveBeenCalledWith({
+ 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: ['MO', 'WE', 'FR'],
+ description: 'test description',
+ end: 'never',
+ frequency: 'week',
+ interval: 1,
+ name: 'Run weekly on mon/wed/fri',
+ occurrences: 1,
+ runOn: 'number',
+ startDateTime: '2020-03-25T10:45:00',
+ timezone: 'America/New_York',
+ });
+ });
+ 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',
+ });
+ });
+ 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: 'number',
+ startDateTime: '2020-04-01T10:45',
+ timezone: 'America/New_York',
+ });
+ });
+ expect(createSchedule).toHaveBeenCalledWith({
+ 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=01',
+ });
+ });
+ 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: 'lastDay',
+ startDateTime: '2020-03-31T11:00',
+ timezone: 'America/New_York',
+ });
+ });
+ expect(createSchedule).toHaveBeenCalledWith({
+ description: 'test description',
+ name: 'Run monthly on the last Tuesday',
+ rrule:
+ 'DTSTART;TZID=America/New_York:20200331T110000 RRULE:INTERVAL=1;FREQ=MONTHLY;BYDAY=TU;BYSETPOS=-1',
+ });
+ });
+ 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: 'number',
+ startDateTime: '2020-03-01T00:00',
+ timezone: 'America/New_York',
+ });
+ });
+ expect(createSchedule).toHaveBeenCalledWith({
+ 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=01',
+ });
+ });
+ 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: 'day',
+ startDateTime: '2020-04-10T11:15',
+ timezone: 'America/New_York',
+ });
+ });
+ expect(createSchedule).toHaveBeenCalledWith({
+ description: 'test description',
+ name: 'Yearly on the second Friday in April',
+ rrule:
+ 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=4;BYDAY=FR;BYSETPOS=2',
+ });
+ });
+});
diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/index.js b/awx/ui_next/src/components/Schedule/ScheduleAdd/index.js
new file mode 100644
index 0000000000..74abeba5d5
--- /dev/null
+++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/index.js
@@ -0,0 +1 @@
+export { default } from './ScheduleAdd';
diff --git a/awx/ui_next/src/components/Schedule/Schedules.jsx b/awx/ui_next/src/components/Schedule/Schedules.jsx
index 4866aba404..297c9c475a 100644
--- a/awx/ui_next/src/components/Schedule/Schedules.jsx
+++ b/awx/ui_next/src/components/Schedule/Schedules.jsx
@@ -1,18 +1,23 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { Switch, Route, useRouteMatch } from 'react-router-dom';
-import { Schedule, ScheduleList } from '@components/Schedule';
+import { Schedule, ScheduleAdd, ScheduleList } from '@components/Schedule';
function Schedules({
+ createSchedule,
+ loadScheduleOptions,
+ loadSchedules,
setBreadcrumb,
unifiedJobTemplate,
- loadSchedules,
- loadScheduleOptions,
}) {
const match = useRouteMatch();
return (
+ }
+ />
{
+ if (typeof value === 'number') {
+ if (!Number.isInteger(value)) {
+ return i18n._(t`This field must an integer`);
+ }
+ if (value < 1) {
+ return i18n._(t`This field must be greater than 0`);
+ }
+ }
+ if (!value) {
+ return i18n._(t`Select a value for this field`);
+ }
+ return undefined;
+ };
+}
+
+const FrequencyDetailSubform = ({ i18n }) => {
+ const [startDateTime] = useField({
+ name: 'startDateTime',
+ });
+ const [daysOfWeek, daysOfWeekMeta, daysOfWeekHelpers] = useField({
+ name: 'daysOfWeek',
+ validate: required(i18n._(t`Select a value for this field`), i18n),
+ });
+ const [end, endMeta] = useField({
+ name: 'end',
+ validate: required(i18n._(t`Select a value for this field`), i18n),
+ });
+ const [interval, intervalMeta] = useField({
+ name: 'interval',
+ validate: requiredPositiveInteger(i18n),
+ });
+ const [runOn, runOnMeta, runOnHelpers] = useField({
+ name: 'runOn',
+ validate: required(i18n._(t`Select a value for this field`), i18n),
+ });
+ const [endDateTime, endDateTimeMeta] = useField({
+ name: 'endDateTime',
+ validate: required(i18n._(t`Select a value for this field`), i18n),
+ });
+ const [frequency] = useField({
+ name: 'frequency',
+ });
+ useField({
+ name: 'occurrences',
+ validate: requiredPositiveInteger(i18n),
+ });
+
+ useEffect(() => {
+ // The Last day option disappears if the start date isn't in the
+ // last week of the month. If that value was selected when this
+ // happens then we'll clear out the selection and force the user
+ // to choose between the remaining two.
+ if (
+ (frequency.value === 'month' || frequency.value === 'year') &&
+ runOn.value === 'lastDay' &&
+ getDaysInMonth(startDateTime.value) - 7 >=
+ new Date(startDateTime.value).getDate()
+ ) {
+ runOnHelpers.setValue('');
+ }
+ }, [startDateTime.value, frequency.value, runOn.value, runOnHelpers]);
+
+ const updateDaysOfWeek = (day, checked) => {
+ const newDaysOfWeek = [...daysOfWeek.value];
+ if (checked) {
+ newDaysOfWeek.push(day);
+ daysOfWeekHelpers.setValue(newDaysOfWeek);
+ } else {
+ daysOfWeekHelpers.setValue(
+ newDaysOfWeek.filter(selectedDay => selectedDay !== day)
+ );
+ }
+ };
+
+ const getRunEveryLabel = () => {
+ switch (frequency.value) {
+ case 'minute':
+ return i18n.plural({
+ value: interval.value,
+ one: 'minute',
+ other: 'minutes',
+ });
+ case 'hour':
+ return i18n.plural({
+ value: interval.value,
+ one: 'hour',
+ other: 'hours',
+ });
+ case 'day':
+ return i18n.plural({
+ value: interval.value,
+ one: 'day',
+ other: 'days',
+ });
+ case 'week':
+ return i18n.plural({
+ value: interval.value,
+ one: 'week',
+ other: 'weeks',
+ });
+ case 'month':
+ return i18n.plural({
+ value: interval.value,
+ one: 'month',
+ other: 'months',
+ });
+ case 'year':
+ return i18n.plural({
+ value: interval.value,
+ one: 'year',
+ other: 'years',
+ });
+ default:
+ throw new Error(i18n._(t`Frequency did not match an expected value`));
+ }
+ };
+
+ const generateRunOnNumberLabel = () => {
+ switch (frequency.value) {
+ case 'month':
+ return i18n._(
+ t`Day ${startDateTime.value.split('T')[0].split('-')[2]}`
+ );
+ case 'year': {
+ const monthString = getMonthString(
+ new Date(startDateTime.value).getMonth(),
+ i18n
+ );
+ return `${monthString} ${new Date(startDateTime.value).getDate()}`;
+ }
+ default:
+ throw new Error(i18n._(t`Frequency did not match an expected value`));
+ }
+ };
+
+ const generateRunOnDayLabel = () => {
+ const dayString = getDayString(
+ new Date(startDateTime.value).getDay(),
+ i18n
+ );
+ const weekNumber = getWeekNumber(startDateTime.value);
+ const weekString = getWeekString(weekNumber, i18n);
+ switch (frequency.value) {
+ case 'month':
+ return i18n._(t`The ${weekString} ${dayString}`);
+ case 'year': {
+ const monthString = getMonthString(
+ new Date(startDateTime.value).getMonth(),
+ i18n
+ );
+ return i18n._(t`The ${weekString} ${dayString} in ${monthString}`);
+ }
+ default:
+ throw new Error(i18n._(t`Frequency did not match an expected value`));
+ }
+ };
+
+ const generateRunOnLastDayLabel = () => {
+ const dayString = getDayString(
+ new Date(startDateTime.value).getDay(),
+ i18n
+ );
+ switch (frequency.value) {
+ case 'month':
+ return i18n._(t`The last ${dayString}`);
+ case 'year': {
+ const monthString = getMonthString(
+ new Date(startDateTime.value).getMonth(),
+ i18n
+ );
+ return i18n._(t`The last ${dayString} in ${monthString}`);
+ }
+ default:
+ throw new Error(i18n._(t`Frequency did not match an expected value`));
+ }
+ };
+
+ /* eslint-disable no-restricted-globals */
+ return (
+ <>
+
+