From d9b613ccb3617835257a168100f4bd0f10731099 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 25 Mar 2020 11:59:57 -0400 Subject: [PATCH 1/7] Implement schedule add form on JT/WFJT/Proj --- awx/ui_next/src/api/mixins/Schedules.mixin.js | 4 + awx/ui_next/src/api/models/Schedules.js | 4 + .../DeleteRoleConfirmationModal.test.jsx.snap | 30 +- .../Schedule/ScheduleAdd/ScheduleAdd.jsx | 168 +++++++ .../Schedule/ScheduleAdd/ScheduleAdd.test.jsx | 227 +++++++++ .../components/Schedule/ScheduleAdd/index.js | 1 + .../src/components/Schedule/Schedules.jsx | 11 +- awx/ui_next/src/components/Schedule/index.js | 1 + .../shared/FrequencyDetailSubform.jsx | 445 ++++++++++++++++++ .../Schedule/shared/ScheduleForm.jsx | 230 +++++++++ .../Schedule/shared/ScheduleForm.test.jsx | 415 ++++++++++++++++ awx/ui_next/src/screens/Project/Project.jsx | 7 + .../ProjectDetail/ProjectDetail.test.jsx | 17 +- awx/ui_next/src/screens/Project/Projects.jsx | 1 + awx/ui_next/src/screens/Template/Template.jsx | 5 + .../src/screens/Template/Templates.jsx | 3 + .../screens/Template/WorkflowJobTemplate.jsx | 7 + .../User/UserDetail/UserDetail.test.jsx | 17 +- awx/ui_next/src/util/dates.jsx | 100 ++++ awx/ui_next/src/util/dates.test.jsx | 110 ++++- awx/ui_next/testUtils/enzymeHelpers.jsx | 9 +- 21 files changed, 1760 insertions(+), 52 deletions(-) create mode 100644 awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx create mode 100644 awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx create mode 100644 awx/ui_next/src/components/Schedule/ScheduleAdd/index.js create mode 100644 awx/ui_next/src/components/Schedule/shared/FrequencyDetailSubform.jsx create mode 100644 awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx create mode 100644 awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx diff --git a/awx/ui_next/src/api/mixins/Schedules.mixin.js b/awx/ui_next/src/api/mixins/Schedules.mixin.js index 4ea44f418e..d7dad6d40a 100644 --- a/awx/ui_next/src/api/mixins/Schedules.mixin.js +++ b/awx/ui_next/src/api/mixins/Schedules.mixin.js @@ -1,5 +1,9 @@ const SchedulesMixin = parent => class extends parent { + createSchedule(id, data) { + return this.http.post(`${this.baseUrl}${id}/schedules/`, data); + } + readSchedules(id, params) { return this.http.get(`${this.baseUrl}${id}/schedules/`, { params }); } diff --git a/awx/ui_next/src/api/models/Schedules.js b/awx/ui_next/src/api/models/Schedules.js index 01bff4891e..7f20e992ae 100644 --- a/awx/ui_next/src/api/models/Schedules.js +++ b/awx/ui_next/src/api/models/Schedules.js @@ -13,6 +13,10 @@ class Schedules extends Base { readCredentials(resourceId, params) { return this.http.get(`${this.baseUrl}${resourceId}/credentials/`, params); } + + readZoneInfo() { + return this.http.get(`${this.baseUrl}zoneinfo/`); + } } export default Schedules; diff --git a/awx/ui_next/src/components/ResourceAccessList/__snapshots__/DeleteRoleConfirmationModal.test.jsx.snap b/awx/ui_next/src/components/ResourceAccessList/__snapshots__/DeleteRoleConfirmationModal.test.jsx.snap index 0511d4763f..795f398ba2 100644 --- a/awx/ui_next/src/components/ResourceAccessList/__snapshots__/DeleteRoleConfirmationModal.test.jsx.snap +++ b/awx/ui_next/src/components/ResourceAccessList/__snapshots__/DeleteRoleConfirmationModal.test.jsx.snap @@ -37,7 +37,7 @@ exports[` should render initially 1`] = ` } isOpen={true} onClose={[Function]} - title="Remove {0} Access" + title="Remove Team Access" variant="danger" > should render initially 1`] = ` > @@ -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`] = ` > @@ -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 ( + <> + +
+ { + interval.onChange(event); + }} + /> + {getRunEveryLabel()} +
+
+ {frequency?.value === 'week' && ( + +
+ { + updateDaysOfWeek('SU', checked); + }} + aria-label={i18n._(t`Sunday`)} + id="days-of-week-sun" + name="daysOfWeek" + /> + { + updateDaysOfWeek('MO', checked); + }} + aria-label={i18n._(t`Monday`)} + id="days-of-week-mon" + name="daysOfWeek" + /> + { + updateDaysOfWeek('TU', checked); + }} + aria-label={i18n._(t`Tuesday`)} + id="days-of-week-tue" + name="daysOfWeek" + /> + { + updateDaysOfWeek('WE', checked); + }} + aria-label={i18n._(t`Wednesday`)} + id="days-of-week-wed" + name="daysOfWeek" + /> + { + updateDaysOfWeek('TH', checked); + }} + aria-label={i18n._(t`Thursday`)} + id="days-of-week-thu" + name="daysOfWeek" + /> + { + updateDaysOfWeek('FR', checked); + }} + aria-label={i18n._(t`Friday`)} + id="days-of-week-fri" + name="daysOfWeek" + /> + { + updateDaysOfWeek('SA', checked); + }} + aria-label={i18n._(t`Saturday`)} + id="days-of-week-sat" + name="daysOfWeek" + /> +
+
+ )} + {(frequency?.value === 'month' || frequency?.value === 'year') && + !isNaN(new Date(startDateTime.value)) && ( + + { + event.target.value = 'number'; + runOn.onChange(event); + }} + /> + { + event.target.value = 'day'; + runOn.onChange(event); + }} + /> + {new Date(startDateTime.value).getDate() > + getDaysInMonth(startDateTime.value) - 7 && ( + { + event.target.value = 'lastDay'; + runOn.onChange(event); + }} + /> + )} + + )} + + { + event.target.value = 'never'; + end.onChange(event); + }} + /> + { + event.target.value = 'after'; + end.onChange(event); + }} + /> + { + event.target.value = 'onDate'; + end.onChange(event); + }} + /> + + {end?.value === 'after' && ( + + )} + {end?.value === 'onDate' && ( + + + + )} + + ); + /* eslint-enable no-restricted-globals */ +}; + +export default withI18n()(FrequencyDetailSubform); diff --git a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx new file mode 100644 index 0000000000..598192aa80 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx @@ -0,0 +1,230 @@ +import React, { useEffect, useCallback } from 'react'; +import { shape, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Formik, useField } from 'formik'; +import { Config } from '@contexts/Config'; +import { Form, FormGroup, Title } from '@patternfly/react-core'; +import { SchedulesAPI } from '@api'; +import AnsibleSelect from '@components/AnsibleSelect'; +import ContentError from '@components/ContentError'; +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 useRequest from '@util/useRequest'; +import { required } from '@util/validators'; +import FrequencyDetailSubform from './FrequencyDetailSubform'; + +function ScheduleFormFields({ i18n, zoneOptions }) { + const [startDateTime, startDateTimeMeta] = useField({ + name: 'startDateTime', + validate: required( + i18n._(t`Select a valid date and time for this field`), + i18n + ), + }); + const [timezone, timezoneMeta] = useField({ + name: 'timezone', + validate: required(i18n._(t`Select a value for this field`), i18n), + }); + const [frequency, frequencyMeta] = useField({ + name: 'frequency', + validate: required(i18n._(t`Select a value for this field`), i18n), + }); + + return ( + <> + + + + + + + + + + + + {frequency.value !== 'none' && ( + + {i18n._(t`Frequency Details`)} + + + + + )} + + ); +} + +function ScheduleForm({ + handleCancel, + handleSubmit, + i18n, + schedule, + submitError, + ...rest +}) { + const { + request: loadZoneInfo, + error: contentError, + contentLoading, + result: zoneOptions, + } = useRequest( + useCallback(async () => { + const { data } = await SchedulesAPI.readZoneInfo(); + return data.map(zone => { + return { + value: zone.name, + key: zone.name, + label: zone.name, + }; + }); + }, []) + ); + + useEffect(() => { + loadZoneInfo(); + }, [loadZoneInfo]); + + if (contentError) { + return ; + } + + if (contentLoading) { + return ; + } + + return ( + + {() => { + const now = new Date(); + const closestQuarterHour = new Date( + Math.ceil(now.getTime() / 900000) * 900000 + ); + const tomorrow = new Date(closestQuarterHour); + tomorrow.setDate(tomorrow.getDate() + 1); + return ( + { + const errors = {}; + const { end, endDateTime, startDateTime } = values; + + if ( + end === 'onDate' && + new Date(startDateTime) > new Date(endDateTime) + ) { + errors.endDateTime = i18n._( + t`Please select an end date/time that comes after the start date/time.` + ); + } + + return errors; + }} + > + {formik => ( +
+ + + + + +
+ )} +
+ ); + }} +
+ ); +} + +ScheduleForm.propTypes = { + handleCancel: func.isRequired, + handleSubmit: func.isRequired, + schedule: shape({}), + submitError: shape(), +}; + +ScheduleForm.defaultProps = { + schedule: {}, + submitError: null, +}; + +export default withI18n()(ScheduleForm); diff --git a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx new file mode 100644 index 0000000000..144182c6c7 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx @@ -0,0 +1,415 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { SchedulesAPI } from '@api'; +import ScheduleForm from './ScheduleForm'; + +jest.mock('@api/models/Schedules'); + +let wrapper; + +const defaultFieldsVisible = () => { + expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Start date/time"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Local time zone"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Run frequency"]').length).toBe(1); +}; + +describe('', () => { + describe('Error', () => { + test('should display error when error occurs while loading', async () => { + SchedulesAPI.readZoneInfo.mockRejectedValue( + new Error({ + response: { + config: { + method: 'get', + url: '/api/v2/schedules/zoneinfo', + }, + data: 'An error occurred', + status: 500, + }, + }) + ); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + expect(wrapper.find('ContentError').length).toBe(1); + }); + }); + describe('Cancel', () => { + test('should make the appropriate callback', async () => { + const handleCancel = jest.fn(); + SchedulesAPI.readZoneInfo.mockResolvedValue({ + data: [ + { + name: 'America/New_York', + }, + ], + }); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').simulate('click'); + }); + expect(handleCancel).toHaveBeenCalledTimes(1); + }); + }); + describe('Add', () => { + beforeAll(async () => { + SchedulesAPI.readZoneInfo.mockResolvedValue({ + data: [ + { + name: 'America/New_York', + }, + ], + }); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + }); + afterAll(() => { + wrapper.unmount(); + }); + test('initially renders expected fields and values', () => { + 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); + + expect(wrapper.find('input#schedule-name').prop('value')).toBe(''); + expect(wrapper.find('input#schedule-description').prop('value')).toBe(''); + expect( + wrapper.find('input#schedule-start-datetime').prop('value') + ).toMatch(/\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d/); + expect(wrapper.find('select#schedule-timezone').prop('value')).toBe( + 'America/New_York' + ); + expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( + 'none' + ); + }); + test('correct frequency details fields and values shown when frequency changed to minute', async () => { + const runFrequencySelect = wrapper.find( + 'FormGroup[label="Run frequency"] FormSelect' + ); + await act(async () => { + runFrequencySelect.invoke('onChange')('minute', { + target: { value: 'minute', key: 'minute', label: 'Minute' }, + }); + }); + wrapper.update(); + 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); + + expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); + 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('correct frequency details fields and values shown when frequency changed to hour', async () => { + const runFrequencySelect = wrapper.find( + 'FormGroup[label="Run frequency"] FormSelect' + ); + await act(async () => { + runFrequencySelect.invoke('onChange')('hour', { + target: { value: 'hour', key: 'hour', label: 'Hour' }, + }); + }); + wrapper.update(); + 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); + + expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); + 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('correct frequency details fields and values shown when frequency changed to day', async () => { + const runFrequencySelect = wrapper.find( + 'FormGroup[label="Run frequency"] FormSelect' + ); + await act(async () => { + runFrequencySelect.invoke('onChange')('day', { + target: { value: 'day', key: 'day', label: 'Day' }, + }); + }); + wrapper.update(); + 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); + + expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); + 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('correct frequency details fields and values shown when frequency changed to week', async () => { + const runFrequencySelect = wrapper.find( + 'FormGroup[label="Run frequency"] FormSelect' + ); + await act(async () => { + runFrequencySelect.invoke('onChange')('week', { + target: { value: 'week', key: 'week', label: 'Week' }, + }); + }); + wrapper.update(); + 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(1); + 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); + + expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); + 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('correct frequency details fields and values shown when frequency changed to month', async () => { + const runFrequencySelect = wrapper.find( + 'FormGroup[label="Run frequency"] FormSelect' + ); + await act(async () => { + runFrequencySelect.invoke('onChange')('month', { + target: { value: 'month', key: 'month', label: 'Month' }, + }); + }); + wrapper.update(); + 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(1); + expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); + + expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); + 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('month run on options displayed correctly as date changes', async () => { + await act(async () => { + wrapper.find('input#schedule-start-datetime').simulate('change', { + target: { value: '2020-03-23T01:45:00', name: 'startDateTime' }, + }); + }); + wrapper.update(); + expect(wrapper.find('input#run-on-number').prop('checked')).toBe(true); + expect(wrapper.find('input#run-on-number + label').text()).toBe('Day 23'); + expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false); + expect(wrapper.find('input#run-on-day + label').text()).toBe( + 'The fourth Monday' + ); + expect(wrapper.find('input#run-on-last-day').length).toBe(0); + await act(async () => { + wrapper.find('input#schedule-start-datetime').simulate('change', { + target: { value: '2020-03-27T01:45:00', name: 'startDateTime' }, + }); + }); + wrapper.update(); + expect(wrapper.find('input#run-on-number').prop('checked')).toBe(true); + expect(wrapper.find('input#run-on-number + label').text()).toBe('Day 27'); + expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false); + expect(wrapper.find('input#run-on-day + label').text()).toBe( + 'The fourth Friday' + ); + expect(wrapper.find('input#run-on-last-day').prop('checked')).toBe(false); + expect(wrapper.find('input#run-on-last-day + label').text()).toBe( + 'The last Friday' + ); + }); + test('month run on cleared when last day selected but date changes from one of the last seven days of the month', async () => { + await act(async () => { + wrapper.find('Radio#run-on-last-day').invoke('onChange')('lastDay', { + target: { name: 'runOn' }, + }); + }); + wrapper.update(); + expect(wrapper.find('input#run-on-number').prop('checked')).toBe(false); + expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false); + expect(wrapper.find('input#run-on-last-day').prop('checked')).toBe(true); + await act(async () => { + wrapper.find('input#schedule-start-datetime').simulate('change', { + target: { value: '2020-03-15T01:45:00', name: 'startDateTime' }, + }); + }); + wrapper.update(); + expect(wrapper.find('input#run-on-number').prop('checked')).toBe(false); + expect(wrapper.find('input#run-on-number + label').text()).toBe('Day 15'); + expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false); + expect(wrapper.find('input#run-on-day + label').text()).toBe( + 'The third Sunday' + ); + expect(wrapper.find('input#run-on-last-day').length).toBe(0); + await act(async () => { + wrapper.find('Radio#run-on-number').invoke('onChange')('number', { + target: { name: 'runOn' }, + }); + }); + wrapper.update(); + }); + test('correct frequency details fields and values shown when frequency changed to year', async () => { + const runFrequencySelect = wrapper.find( + 'FormGroup[label="Run frequency"] FormSelect' + ); + await act(async () => { + runFrequencySelect.invoke('onChange')('year', { + target: { value: 'year', key: 'year', label: 'Year' }, + }); + }); + wrapper.update(); + 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(1); + expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); + + expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); + 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('year run on options displayed correctly as date changes', async () => { + await act(async () => { + wrapper.find('input#schedule-start-datetime').simulate('change', { + target: { value: '2020-03-23T01:45:00', name: 'startDateTime' }, + }); + }); + wrapper.update(); + expect(wrapper.find('input#run-on-number').prop('checked')).toBe(true); + expect(wrapper.find('input#run-on-number + label').text()).toBe( + 'March 23' + ); + expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false); + expect(wrapper.find('input#run-on-day + label').text()).toBe( + 'The fourth Monday in March' + ); + expect(wrapper.find('input#run-on-last-day').length).toBe(0); + await act(async () => { + wrapper.find('input#schedule-start-datetime').simulate('change', { + target: { value: '2020-03-27T01:45:00', name: 'startDateTime' }, + }); + }); + wrapper.update(); + expect(wrapper.find('input#run-on-number').prop('checked')).toBe(true); + expect(wrapper.find('input#run-on-number + label').text()).toBe( + 'March 27' + ); + expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false); + expect(wrapper.find('input#run-on-day + label').text()).toBe( + 'The fourth Friday in March' + ); + expect(wrapper.find('input#run-on-last-day').prop('checked')).toBe(false); + expect(wrapper.find('input#run-on-last-day + label').text()).toBe( + 'The last Friday in March' + ); + }); + test('occurrences field properly shown when that run on selection is made', async () => { + await act(async () => { + wrapper.find('Radio#end-after').invoke('onChange')('after', { + target: { name: 'end' }, + }); + }); + wrapper.update(); + 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('FormGroup[label="Occurrences"]').length).toBe(1); + expect(wrapper.find('input#schedule-occurrences').prop('value')).toBe(1); + await act(async () => { + wrapper.find('Radio#end-never').invoke('onChange')('never', { + target: { name: 'end' }, + }); + }); + wrapper.update(); + }); + test('year run on cleared when last day selected but date changes from one of the last seven days of the month', async () => { + await act(async () => { + wrapper.find('Radio#run-on-last-day').invoke('onChange')('lastDay', { + target: { name: 'runOn' }, + }); + }); + wrapper.update(); + expect(wrapper.find('input#run-on-number').prop('checked')).toBe(false); + expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false); + expect(wrapper.find('input#run-on-last-day').prop('checked')).toBe(true); + await act(async () => { + wrapper.find('input#schedule-start-datetime').simulate('change', { + target: { value: '2020-03-15T01:45:00', name: 'startDateTime' }, + }); + }); + wrapper.update(); + expect(wrapper.find('input#run-on-number').prop('checked')).toBe(false); + expect(wrapper.find('input#run-on-number + label').text()).toBe( + 'March 15' + ); + expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false); + expect(wrapper.find('input#run-on-day + label').text()).toBe( + 'The third Sunday in March' + ); + expect(wrapper.find('input#run-on-last-day').length).toBe(0); + }); + test('error shown when end date/time comes before start date/time', async () => { + 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); + await act(async () => { + wrapper.find('Radio#end-on-date').invoke('onChange')('onDate', { + target: { name: 'end' }, + }); + }); + wrapper.update(); + 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('#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.update(); + + setTimeout(() => { + expect(wrapper.find('#schedule-end-datetime-helper').text()).toBe( + 'Please select an end date/time that comes after the start date/time.' + ); + }); + }); + }); +}); diff --git a/awx/ui_next/src/screens/Project/Project.jsx b/awx/ui_next/src/screens/Project/Project.jsx index 1f75a05c79..1161e3f378 100644 --- a/awx/ui_next/src/screens/Project/Project.jsx +++ b/awx/ui_next/src/screens/Project/Project.jsx @@ -26,6 +26,7 @@ class Project extends Component { isInitialized: false, isNotifAdmin: false, }; + this.createSchedule = this.createSchedule.bind(this); this.loadProject = this.loadProject.bind(this); this.loadProjectAndRoles = this.loadProjectAndRoles.bind(this); this.loadSchedules = this.loadSchedules.bind(this); @@ -91,6 +92,11 @@ class Project extends Component { } } + createSchedule(data) { + const { project } = this.state; + return ProjectsAPI.createSchedule(project.id, data); + } + loadScheduleOptions() { const { project } = this.state; return ProjectsAPI.readScheduleOptions(project.id); @@ -233,6 +239,7 @@ class Project extends Component { diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx index 33f8e71254..066e03684a 100644 --- a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx @@ -66,22 +66,7 @@ describe('', () => { }); test('should render Details', () => { - const wrapper = mountWithContexts(, { - context: { - linguiPublisher: { - i18n: { - _: key => { - if (key.values) { - Object.entries(key.values).forEach(([k, v]) => { - key.id = key.id.replace(new RegExp(`\\{${k}\\}`), v); - }); - } - return key.id; - }, - }, - }, - }, - }); + const wrapper = mountWithContexts(); function assertDetail(label, value) { expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); diff --git a/awx/ui_next/src/screens/Project/Projects.jsx b/awx/ui_next/src/screens/Project/Projects.jsx index ced814313f..0c25bfb0a2 100644 --- a/awx/ui_next/src/screens/Project/Projects.jsx +++ b/awx/ui_next/src/screens/Project/Projects.jsx @@ -44,6 +44,7 @@ class Projects extends Component { [`/projects/${project.id}/job_templates`]: i18n._(t`Job Templates`), [`${projectSchedulesPath}`]: i18n._(t`Schedules`), + [`${projectSchedulesPath}/add`]: i18n._(t`Create New Schedule`), [`${projectSchedulesPath}/${nested?.id}`]: `${nested?.name}`, [`${projectSchedulesPath}/${nested?.id}/details`]: i18n._( t`Edit Details` diff --git a/awx/ui_next/src/screens/Template/Template.jsx b/awx/ui_next/src/screens/Template/Template.jsx index 36d2763ecd..f68427cb89 100644 --- a/awx/ui_next/src/screens/Template/Template.jsx +++ b/awx/ui_next/src/screens/Template/Template.jsx @@ -59,6 +59,10 @@ function Template({ i18n, me, setBreadcrumb }) { loadTemplateAndRoles(); }, [loadTemplateAndRoles, location.pathname]); + const createSchedule = data => { + return JobTemplatesAPI.createSchedule(templateId, data); + }; + const loadScheduleOptions = () => { return JobTemplatesAPI.readScheduleOptions(templateId); }; @@ -173,6 +177,7 @@ function Template({ i18n, me, setBreadcrumb }) { path="/templates/:templateType/:id/schedules" > diff --git a/awx/ui_next/src/screens/User/UserDetail/UserDetail.test.jsx b/awx/ui_next/src/screens/User/UserDetail/UserDetail.test.jsx index 69ec4f36e7..48c706913e 100644 --- a/awx/ui_next/src/screens/User/UserDetail/UserDetail.test.jsx +++ b/awx/ui_next/src/screens/User/UserDetail/UserDetail.test.jsx @@ -14,22 +14,7 @@ describe('', () => { }); test('should render Details', () => { - const wrapper = mountWithContexts(, { - context: { - linguiPublisher: { - i18n: { - _: key => { - if (key.values) { - Object.entries(key.values).forEach(([k, v]) => { - key.id = key.id.replace(new RegExp(`\\{${k}\\}`), v); - }); - } - return key.id; - }, - }, - }, - }, - }); + const wrapper = mountWithContexts(); function assertDetail(label, value) { expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); diff --git a/awx/ui_next/src/util/dates.jsx b/awx/ui_next/src/util/dates.jsx index ca897142e4..b605f79abf 100644 --- a/awx/ui_next/src/util/dates.jsx +++ b/awx/ui_next/src/util/dates.jsx @@ -1,6 +1,9 @@ /* eslint-disable import/prefer-default-export */ +import { t } from '@lingui/macro'; import { getLanguage } from './language'; +const prependZeros = value => value.toString().padStart(2, 0); + export function formatDateString(dateString, lang = getLanguage(navigator)) { return new Date(dateString).toLocaleString(lang); } @@ -12,3 +15,100 @@ export function formatDateStringUTC(dateString, lang = getLanguage(navigator)) { export function secondsToHHMMSS(seconds) { return new Date(seconds * 1000).toISOString().substr(11, 8); } + +export function dateToInputDateTime(dateObj) { + // input type="date-time" expects values to be formatted + // like: YYYY-MM-DDTHH-MM-SS + const year = dateObj.getFullYear(); + const month = prependZeros(dateObj.getMonth() + 1); + const day = prependZeros(dateObj.getDate()); + const hour = prependZeros(dateObj.getHours()); + const minute = prependZeros(dateObj.getMinutes()); + const second = prependZeros(dateObj.getSeconds()); + return `${year}-${month}-${day}T${hour}:${minute}:${second}`; +} + +export function getDaysInMonth(dateString) { + const dateObj = new Date(dateString); + return new Date(dateObj.getFullYear(), dateObj.getMonth() + 1, 0).getDate(); +} + +export function getWeekNumber(dateString) { + const dateObj = new Date(dateString); + const dayOfMonth = dateObj.getDate(); + const dayOfWeek = dateObj.getDay(); + if (dayOfMonth < 8) { + return 1; + } + dateObj.setDate(dayOfMonth - dayOfWeek + 1); + return Math.ceil(dayOfMonth / 7); +} + +export function getDayString(dayIndex, i18n) { + switch (dayIndex) { + case 0: + return i18n._(t`Sunday`); + case 1: + return i18n._(t`Monday`); + case 2: + return i18n._(t`Tuesday`); + case 3: + return i18n._(t`Wednesday`); + case 4: + return i18n._(t`Thursday`); + case 5: + return i18n._(t`Friday`); + case 6: + return i18n._(t`Saturday`); + default: + throw new Error(i18n._(t`Unrecognized day index`)); + } +} + +export function getWeekString(weekNumber, i18n) { + switch (weekNumber) { + case 1: + return i18n._(t`first`); + case 2: + return i18n._(t`second`); + case 3: + return i18n._(t`third`); + case 4: + return i18n._(t`fourth`); + case 5: + return i18n._(t`fifth`); + default: + throw new Error(i18n._(t`Unrecognized week number`)); + } +} + +export function getMonthString(monthIndex, i18n) { + switch (monthIndex) { + case 0: + return i18n._(t`January`); + case 1: + return i18n._(t`February`); + case 2: + return i18n._(t`March`); + case 3: + return i18n._(t`April`); + case 4: + return i18n._(t`May`); + case 5: + return i18n._(t`June`); + case 6: + return i18n._(t`July`); + case 7: + return i18n._(t`August`); + case 8: + return i18n._(t`September`); + case 9: + return i18n._(t`October`); + case 10: + return i18n._(t`November`); + case 11: + return i18n._(t`December`); + default: + throw new Error(i18n._(t`Unrecognized month index`)); + } +} diff --git a/awx/ui_next/src/util/dates.test.jsx b/awx/ui_next/src/util/dates.test.jsx index 90b02c1185..4a13701a28 100644 --- a/awx/ui_next/src/util/dates.test.jsx +++ b/awx/ui_next/src/util/dates.test.jsx @@ -1,4 +1,25 @@ -import { formatDateString } from './dates'; +import { + dateToInputDateTime, + getDaysInMonth, + getDayString, + getMonthString, + getWeekNumber, + getWeekString, + formatDateString, + formatDateStringUTC, + secondsToHHMMSS, +} from './dates'; + +const i18n = { + _: key => { + if (key.values) { + Object.entries(key.values).forEach(([k, v]) => { + key.id = key.id.replace(new RegExp(`\\{${k}\\}`), v); + }); + } + return key.id; + }, +}; describe('formatDateString', () => { test('it returns the expected value', () => { @@ -11,3 +32,90 @@ describe('formatDateString', () => { ); }); }); + +describe('formatDateStringUTC', () => { + test('it returns the expected value', () => { + const lang = 'en-US'; + expect(formatDateStringUTC('', lang)).toEqual('Invalid Date'); + expect(formatDateStringUTC({}, lang)).toEqual('Invalid Date'); + expect(formatDateStringUTC(undefined, lang)).toEqual('Invalid Date'); + expect(formatDateStringUTC('2018-01-31T01:14:52.969227Z', lang)).toEqual( + '1/31/2018, 1:14:52 AM' + ); + }); +}); + +describe('secondsToHHMMSS', () => { + test('it returns the expected value', () => { + expect(secondsToHHMMSS(50000)).toEqual('13:53:20'); + }); +}); + +describe('dateToInputDateTime', () => { + test('it returns the expected value', () => { + expect( + dateToInputDateTime(new Date('2018-01-31T01:14:52.969227Z')) + ).toEqual('2018-01-31T01:14:52'); + }); +}); + +describe('getDaysInMonth', () => { + test('it returns the expected value', () => { + expect(getDaysInMonth('2020-02-15T00:00:00Z')).toEqual(29); + expect(getDaysInMonth('2020-03-15T00:00:00Z')).toEqual(31); + expect(getDaysInMonth('2020-04-15T00:00:00Z')).toEqual(30); + }); +}); + +describe('getWeekNumber', () => { + test('it returns the expected value', () => { + expect(getWeekNumber('2020-02-01T00:00:00Z')).toEqual(1); + expect(getWeekNumber('2020-02-08T00:00:00Z')).toEqual(2); + expect(getWeekNumber('2020-02-15T00:00:00Z')).toEqual(3); + expect(getWeekNumber('2020-02-22T00:00:00Z')).toEqual(4); + expect(getWeekNumber('2020-02-29T00:00:00Z')).toEqual(5); + }); +}); + +describe('getDayString', () => { + test('it returns the expected value', () => { + expect(getDayString(0, i18n)).toEqual('Sunday'); + expect(getDayString(1, i18n)).toEqual('Monday'); + expect(getDayString(2, i18n)).toEqual('Tuesday'); + expect(getDayString(3, i18n)).toEqual('Wednesday'); + expect(getDayString(4, i18n)).toEqual('Thursday'); + expect(getDayString(5, i18n)).toEqual('Friday'); + expect(getDayString(6, i18n)).toEqual('Saturday'); + expect(() => getDayString(7, i18n)).toThrow(); + }); +}); + +describe('getWeekString', () => { + test('it returns the expected value', () => { + expect(() => getWeekString(0, i18n)).toThrow(); + expect(getWeekString(1, i18n)).toEqual('first'); + expect(getWeekString(2, i18n)).toEqual('second'); + expect(getWeekString(3, i18n)).toEqual('third'); + expect(getWeekString(4, i18n)).toEqual('fourth'); + expect(getWeekString(5, i18n)).toEqual('fifth'); + expect(() => getWeekString(6, i18n)).toThrow(); + }); +}); + +describe('getMonthString', () => { + test('it returns the expected value', () => { + expect(getMonthString(0, i18n)).toEqual('January'); + expect(getMonthString(1, i18n)).toEqual('February'); + expect(getMonthString(2, i18n)).toEqual('March'); + expect(getMonthString(3, i18n)).toEqual('April'); + expect(getMonthString(4, i18n)).toEqual('May'); + expect(getMonthString(5, i18n)).toEqual('June'); + expect(getMonthString(6, i18n)).toEqual('July'); + expect(getMonthString(7, i18n)).toEqual('August'); + expect(getMonthString(8, i18n)).toEqual('September'); + expect(getMonthString(9, i18n)).toEqual('October'); + expect(getMonthString(10, i18n)).toEqual('November'); + expect(getMonthString(11, i18n)).toEqual('December'); + expect(() => getMonthString(12, i18n)).toThrow(); + }); +}); diff --git a/awx/ui_next/testUtils/enzymeHelpers.jsx b/awx/ui_next/testUtils/enzymeHelpers.jsx index 37d5ee1a5a..bc6666a8ba 100644 --- a/awx/ui_next/testUtils/enzymeHelpers.jsx +++ b/awx/ui_next/testUtils/enzymeHelpers.jsx @@ -27,7 +27,14 @@ const defaultContexts = { linguiPublisher: { i18n: { ...originalI18n, - _: key => key.id, // provide _ macro, for just passing down the key + _: key => { + if (key.values) { + Object.entries(key.values).forEach(([k, v]) => { + key.id = key.id.replace(new RegExp(`\\{${k}\\}`), v); + }); + } + return key.id; + }, // provide _ macro, for just passing down the key toJSON: () => '/i18n/', }, }, From 0c26734d7d353d503c168e775298dfc057981b65 Mon Sep 17 00:00:00 2001 From: mabashian Date: Fri, 27 Mar 2020 09:25:33 -0400 Subject: [PATCH 2/7] Move the construction of the rule object out to it's own function --- .../Schedule/ScheduleAdd/ScheduleAdd.jsx | 210 +++++++++--------- 1 file changed, 106 insertions(+), 104 deletions(-) diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx index 4cbd20231e..c1894ba5bb 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx @@ -26,112 +26,114 @@ 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 === '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`)); + } + + return ruleObj; + }; + 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 rule = new RRule(buildRuleObj(values)); const { data: { id: scheduleId }, } = await createSchedule({ From 24c738c6d8bf165795203418ee2e6e04995fedae Mon Sep 17 00:00:00 2001 From: mabashian Date: Fri, 27 Mar 2020 09:37:32 -0400 Subject: [PATCH 3/7] Moves generation of today and tomorrow strings out of the return of the ScheduleForm --- .../src/components/Schedule/shared/ScheduleForm.jsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx index 598192aa80..b360232d3b 100644 --- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx @@ -121,6 +121,13 @@ function ScheduleForm({ submitError, ...rest }) { + const now = new Date(); + const closestQuarterHour = new Date( + Math.ceil(now.getTime() / 900000) * 900000 + ); + const tomorrow = new Date(closestQuarterHour); + tomorrow.setDate(tomorrow.getDate() + 1); + const { request: loadZoneInfo, error: contentError, @@ -154,12 +161,6 @@ function ScheduleForm({ return ( {() => { - const now = new Date(); - const closestQuarterHour = new Date( - Math.ceil(now.getTime() / 900000) * 900000 - ); - const tomorrow = new Date(closestQuarterHour); - tomorrow.setDate(tomorrow.getDate() + 1); return ( Date: Fri, 27 Mar 2020 09:56:32 -0400 Subject: [PATCH 4/7] Fixes issue where repeat frequency was not displaying correctly for schedules that only run once --- .../src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx index c6ae0b97c4..727496557a 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { rrulestr } from 'rrule'; +import { RRule, rrulestr } from 'rrule'; import styled from 'styled-components'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -66,7 +66,7 @@ function ScheduleDetail({ schedule, i18n }) { const rule = rrulestr(rrule); const repeatFrequency = - rule.options.freq === 3 && dtstart === dtend + rule.options.freq === RRule.MINUTELY && dtstart === dtend ? i18n._(t`None (Run Once)`) : rule.toText().replace(/^\w/, c => c.toUpperCase()); const showPromptedFields = From c7b23aac9b7bf0e2e6b44eba32bbaa9c9b62e82f Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 30 Mar 2020 15:36:48 -0400 Subject: [PATCH 5/7] Removes static run on string options and opts for the more dynamic ux pattern already adopted in the old UI --- .../AnsibleSelect/AnsibleSelect.jsx | 14 +- .../Schedule/ScheduleAdd/ScheduleAdd.jsx | 45 +-- .../Schedule/ScheduleAdd/ScheduleAdd.test.jsx | 52 ++- .../shared/FrequencyDetailSubform.jsx | 347 ++++++++++++------ .../Schedule/shared/ScheduleForm.jsx | 26 +- .../Schedule/shared/ScheduleForm.test.jsx | 142 ++----- awx/ui_next/src/util/dates.jsx | 113 ++---- awx/ui_next/src/util/dates.test.jsx | 93 ++--- 8 files changed, 403 insertions(+), 429 deletions(-) diff --git a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx index cf860ab8f0..bd348998d1 100644 --- a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx +++ b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx @@ -25,7 +25,16 @@ class AnsibleSelect extends React.Component { } render() { - const { id, data, i18n, isValid, onBlur, value, className } = this.props; + const { + id, + data, + i18n, + isValid, + onBlur, + value, + className, + isDisabled, + } = this.props; return ( {data.map(option => ( {}, className: '', + isDisabled: false, }; AnsibleSelect.propTypes = { @@ -72,6 +83,7 @@ AnsibleSelect.propTypes = { onChange: func.isRequired, value: oneOfType([string, number]).isRequired, className: string, + isDisabled: bool, }; export { AnsibleSelect as _AnsibleSelect }; diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx index c1894ba5bb..23f98cd383 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx @@ -6,19 +6,9 @@ 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 { getRRuleDayConstants } 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(); @@ -71,31 +61,22 @@ function ScheduleAdd({ i18n, createSchedule }) { 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; + 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; - 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; + 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: 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 50f1232c25..8a3376e504 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx @@ -64,7 +64,6 @@ describe('', () => { interval: 10, name: 'Run every 10 minutes 10 times', occurrences: 10, - runOn: 'number', startDateTime: '2020-03-25T10:30:00', timezone: 'America/New_York', }); @@ -85,7 +84,6 @@ describe('', () => { frequency: 'hour', interval: 1, name: 'Run every hour until date', - runOn: 'number', startDateTime: '2020-03-25T10:45:00', timezone: 'America/New_York', }); @@ -105,7 +103,6 @@ describe('', () => { frequency: 'day', interval: 1, name: 'Run daily', - runOn: 'number', startDateTime: '2020-03-25T10:45:00', timezone: 'America/New_York', }); @@ -127,7 +124,6 @@ describe('', () => { interval: 1, name: 'Run weekly on mon/wed/fri', occurrences: 1, - runOn: 'number', startDateTime: '2020-03-25T10:45:00', timezone: 'America/New_York', }); @@ -148,7 +144,8 @@ describe('', () => { interval: 1, name: 'Run on the first day of the month', occurrences: 1, - runOn: 'number', + runOn: 'day', + runOnDayNumber: 1, startDateTime: '2020-04-01T10:45', timezone: 'America/New_York', }); @@ -157,7 +154,7 @@ describe('', () => { 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', + '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 () => { @@ -170,7 +167,9 @@ describe('', () => { interval: 1, name: 'Run monthly on the last Tuesday', occurrences: 1, - runOn: 'lastDay', + runOn: 'the', + runOnTheDay: 'tuesday', + runOnTheOccurrence: -1, startDateTime: '2020-03-31T11:00', timezone: 'America/New_York', }); @@ -179,7 +178,7 @@ describe('', () => { 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', + '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 () => { @@ -191,7 +190,9 @@ describe('', () => { interval: 1, name: 'Yearly on the first day of March', occurrences: 1, - runOn: 'number', + runOn: 'day', + runOnDayMonth: 3, + runOnDayNumber: 1, startDateTime: '2020-03-01T00:00', timezone: 'America/New_York', }); @@ -200,7 +201,7 @@ describe('', () => { 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', + '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 () => { @@ -212,7 +213,10 @@ describe('', () => { interval: 1, name: 'Yearly on the second Friday in April', occurrences: 1, - runOn: 'day', + runOn: 'the', + runOnTheOccurrence: 2, + runOnTheDay: 'friday', + runOnTheMonth: 4, startDateTime: '2020-04-10T11:15', timezone: 'America/New_York', }); @@ -221,7 +225,31 @@ describe('', () => { 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', + '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(createSchedule).toHaveBeenCalledWith({ + 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/shared/FrequencyDetailSubform.jsx b/awx/ui_next/src/components/Schedule/shared/FrequencyDetailSubform.jsx index 24fb8f9bbf..2ca554b8dd 100644 --- a/awx/ui_next/src/components/Schedule/shared/FrequencyDetailSubform.jsx +++ b/awx/ui_next/src/components/Schedule/shared/FrequencyDetailSubform.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import styled from 'styled-components'; import { useField } from 'formik'; import { withI18n } from '@lingui/react'; @@ -9,16 +9,25 @@ import { Radio, TextInput, } from '@patternfly/react-core'; +import AnsibleSelect from '@components/AnsibleSelect'; import FormField from '@components/FormField'; -import { - getDaysInMonth, - getDayString, - getMonthString, - getWeekString, - getWeekNumber, -} from '@util/dates'; import { required } from '@util/validators'; +const RunOnRadio = styled(Radio)` + label { + display: block; + width: 100%; + } + + :not(:last-of-type) { + margin-bottom: 10px; + } + + select:not(:first-of-type) { + margin-left: 10px; + } +`; + const RunEveryLabel = styled.p` display: flex; align-items: center; @@ -48,6 +57,21 @@ export function requiredPositiveInteger(i18n) { } const FrequencyDetailSubform = ({ i18n }) => { + const [runOnDayMonth] = useField({ + name: 'runOnDayMonth', + }); + const [runOnDayNumber] = useField({ + name: 'runOnDayNumber', + }); + const [runOnTheOccurrence] = useField({ + name: 'runOnTheOccurrence', + }); + const [runOnTheDay] = useField({ + name: 'runOnTheDay', + }); + const [runOnTheMonth] = useField({ + name: 'runOnTheMonth', + }); const [startDateTime] = useField({ name: 'startDateTime', }); @@ -63,7 +87,7 @@ const FrequencyDetailSubform = ({ i18n }) => { name: 'interval', validate: requiredPositiveInteger(i18n), }); - const [runOn, runOnMeta, runOnHelpers] = useField({ + const [runOn, runOnMeta] = useField({ name: 'runOn', validate: required(i18n._(t`Select a value for this field`), i18n), }); @@ -79,20 +103,64 @@ const FrequencyDetailSubform = ({ i18n }) => { 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 monthOptions = [ + { + key: 'january', + value: 1, + label: i18n._(t`January`), + }, + { + key: 'february', + value: 2, + label: i18n._(t`February`), + }, + { + key: 'march', + value: 3, + label: i18n._(t`March`), + }, + { + key: 'april', + value: 4, + label: i18n._(t`April`), + }, + { + key: 'may', + value: 5, + label: i18n._(t`May`), + }, + { + key: 'june', + value: 6, + label: i18n._(t`June`), + }, + { + key: 'july', + value: 7, + label: i18n._(t`July`), + }, + { key: 'august', value: 8, label: i18n._(t`August`) }, + { + key: 'september', + value: 9, + label: i18n._(t`September`), + }, + { + key: 'october', + value: 10, + label: i18n._(t`October`), + }, + { + key: 'november', + value: 11, + label: i18n._(t`November`), + }, + { + key: 'december', + value: 12, + label: i18n._(t`December`), + }, + ]; const updateDaysOfWeek = (day, checked) => { const newDaysOfWeek = [...daysOfWeek.value]; @@ -149,66 +217,6 @@ const FrequencyDetailSubform = ({ i18n }) => { } }; - 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 ( <> @@ -252,7 +260,7 @@ const FrequencyDetailSubform = ({ i18n }) => { updateDaysOfWeek('SU', checked); }} aria-label={i18n._(t`Sunday`)} - id="days-of-week-sun" + id="schedule-days-of-week-sun" name="daysOfWeek" /> { updateDaysOfWeek('MO', checked); }} aria-label={i18n._(t`Monday`)} - id="days-of-week-mon" + id="schedule-days-of-week-mon" name="daysOfWeek" /> { updateDaysOfWeek('TU', checked); }} aria-label={i18n._(t`Tuesday`)} - id="days-of-week-tue" + id="schedule-days-of-week-tue" name="daysOfWeek" /> { updateDaysOfWeek('WE', checked); }} aria-label={i18n._(t`Wednesday`)} - id="days-of-week-wed" + id="schedule-days-of-week-wed" name="daysOfWeek" /> { updateDaysOfWeek('TH', checked); }} aria-label={i18n._(t`Thursday`)} - id="days-of-week-thu" + id="schedule-days-of-week-thu" name="daysOfWeek" /> { updateDaysOfWeek('FR', checked); }} aria-label={i18n._(t`Friday`)} - id="days-of-week-fri" + id="schedule-days-of-week-fri" name="daysOfWeek" /> { updateDaysOfWeek('SA', checked); }} aria-label={i18n._(t`Saturday`)} - id="days-of-week-sat" + id="schedule-days-of-week-sat" name="daysOfWeek" />
@@ -328,21 +336,39 @@ const FrequencyDetailSubform = ({ i18n }) => { isValid={!runOnMeta.touched || !runOnMeta.error} label={i18n._(t`Run on`)} > - { - event.target.value = 'number'; - runOn.onChange(event); - }} - /> - + {frequency?.value === 'month' && ( + + Day + + )} + {frequency?.value === 'year' && ( + + )} + { + runOnDayNumber.onChange(event); + }} + /> + + } value="day" isChecked={runOn.value === 'day'} onChange={(value, event) => { @@ -350,20 +376,105 @@ const FrequencyDetailSubform = ({ i18n }) => { runOn.onChange(event); }} /> - {new Date(startDateTime.value).getDate() > - getDaysInMonth(startDateTime.value) - 7 && ( - { - event.target.value = 'lastDay'; - runOn.onChange(event); - }} - /> - )} + + + The + + + + {frequency?.value === 'year' && ( + + )} + + } + value="the" + isChecked={runOn.value === 'the'} + onChange={(value, event) => { + event.target.value = 'the'; + runOn.onChange(event); + }} + /> )} { const errors = {}; - const { end, endDateTime, startDateTime } = values; + const { + end, + endDateTime, + frequency, + runOn, + runOnDayNumber, + startDateTime, + } = values; if ( end === 'onDate' && @@ -190,6 +202,16 @@ function ScheduleForm({ ); } + if ( + (frequency === 'month' || frequency === 'year') && + runOn === 'day' && + (runOnDayNumber < 1 || runOnDayNumber > 31) + ) { + errors.runOn = i18n._( + t`Please select a day number between 1 and 31` + ); + } + return errors; }} > 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 144182c6c7..2487f9beee 100644 --- a/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx @@ -216,67 +216,17 @@ describe('', () => { 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('month run on options displayed correctly as date changes', async () => { - await act(async () => { - wrapper.find('input#schedule-start-datetime').simulate('change', { - target: { value: '2020-03-23T01:45:00', name: 'startDateTime' }, - }); - }); - wrapper.update(); - expect(wrapper.find('input#run-on-number').prop('checked')).toBe(true); - expect(wrapper.find('input#run-on-number + label').text()).toBe('Day 23'); - expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false); - expect(wrapper.find('input#run-on-day + label').text()).toBe( - 'The fourth Monday' + expect(wrapper.find('input#schedule-run-on-day').prop('checked')).toBe( + true ); - expect(wrapper.find('input#run-on-last-day').length).toBe(0); - await act(async () => { - wrapper.find('input#schedule-start-datetime').simulate('change', { - target: { value: '2020-03-27T01:45:00', name: 'startDateTime' }, - }); - }); - wrapper.update(); - expect(wrapper.find('input#run-on-number').prop('checked')).toBe(true); - expect(wrapper.find('input#run-on-number + label').text()).toBe('Day 27'); - expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false); - expect(wrapper.find('input#run-on-day + label').text()).toBe( - 'The fourth Friday' + expect( + wrapper.find('input#schedule-run-on-day-number').prop('value') + ).toBe(1); + expect(wrapper.find('input#schedule-run-on-the').prop('checked')).toBe( + false ); - expect(wrapper.find('input#run-on-last-day').prop('checked')).toBe(false); - expect(wrapper.find('input#run-on-last-day + label').text()).toBe( - 'The last Friday' - ); - }); - test('month run on cleared when last day selected but date changes from one of the last seven days of the month', async () => { - await act(async () => { - wrapper.find('Radio#run-on-last-day').invoke('onChange')('lastDay', { - target: { name: 'runOn' }, - }); - }); - wrapper.update(); - expect(wrapper.find('input#run-on-number').prop('checked')).toBe(false); - expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false); - expect(wrapper.find('input#run-on-last-day').prop('checked')).toBe(true); - await act(async () => { - wrapper.find('input#schedule-start-datetime').simulate('change', { - target: { value: '2020-03-15T01:45:00', name: 'startDateTime' }, - }); - }); - wrapper.update(); - expect(wrapper.find('input#run-on-number').prop('checked')).toBe(false); - expect(wrapper.find('input#run-on-number + label').text()).toBe('Day 15'); - expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false); - expect(wrapper.find('input#run-on-day + label').text()).toBe( - 'The third Sunday' - ); - expect(wrapper.find('input#run-on-last-day').length).toBe(0); - await act(async () => { - wrapper.find('Radio#run-on-number').invoke('onChange')('number', { - target: { name: 'runOn' }, - }); - }); - wrapper.update(); + expect(wrapper.find('select#schedule-run-on-day-month').length).toBe(0); + expect(wrapper.find('select#schedule-run-on-the-month').length).toBe(0); }); test('correct frequency details fields and values shown when frequency changed to year', async () => { const runFrequencySelect = wrapper.find( @@ -300,43 +250,19 @@ describe('', () => { 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-on-day').prop('checked')).toBe( + true + ); + expect( + wrapper.find('input#schedule-run-on-day-number').prop('value') + ).toBe(1); + expect(wrapper.find('input#schedule-run-on-the').prop('checked')).toBe( + false + ); + expect(wrapper.find('select#schedule-run-on-day-month').length).toBe(1); + expect(wrapper.find('select#schedule-run-on-the-month').length).toBe(1); }); - test('year run on options displayed correctly as date changes', async () => { - await act(async () => { - wrapper.find('input#schedule-start-datetime').simulate('change', { - target: { value: '2020-03-23T01:45:00', name: 'startDateTime' }, - }); - }); - wrapper.update(); - expect(wrapper.find('input#run-on-number').prop('checked')).toBe(true); - expect(wrapper.find('input#run-on-number + label').text()).toBe( - 'March 23' - ); - expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false); - expect(wrapper.find('input#run-on-day + label').text()).toBe( - 'The fourth Monday in March' - ); - expect(wrapper.find('input#run-on-last-day').length).toBe(0); - await act(async () => { - wrapper.find('input#schedule-start-datetime').simulate('change', { - target: { value: '2020-03-27T01:45:00', name: 'startDateTime' }, - }); - }); - wrapper.update(); - expect(wrapper.find('input#run-on-number').prop('checked')).toBe(true); - expect(wrapper.find('input#run-on-number + label').text()).toBe( - 'March 27' - ); - expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false); - expect(wrapper.find('input#run-on-day + label').text()).toBe( - 'The fourth Friday in March' - ); - expect(wrapper.find('input#run-on-last-day').prop('checked')).toBe(false); - expect(wrapper.find('input#run-on-last-day + label').text()).toBe( - 'The last Friday in March' - ); - }); - test('occurrences field properly shown when that run on selection is made', async () => { + test('occurrences field properly shown when end after selection is made', async () => { await act(async () => { wrapper.find('Radio#end-after').invoke('onChange')('after', { target: { name: 'end' }, @@ -355,32 +281,6 @@ describe('', () => { }); wrapper.update(); }); - test('year run on cleared when last day selected but date changes from one of the last seven days of the month', async () => { - await act(async () => { - wrapper.find('Radio#run-on-last-day').invoke('onChange')('lastDay', { - target: { name: 'runOn' }, - }); - }); - wrapper.update(); - expect(wrapper.find('input#run-on-number').prop('checked')).toBe(false); - expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false); - expect(wrapper.find('input#run-on-last-day').prop('checked')).toBe(true); - await act(async () => { - wrapper.find('input#schedule-start-datetime').simulate('change', { - target: { value: '2020-03-15T01:45:00', name: 'startDateTime' }, - }); - }); - wrapper.update(); - expect(wrapper.find('input#run-on-number').prop('checked')).toBe(false); - expect(wrapper.find('input#run-on-number + label').text()).toBe( - 'March 15' - ); - expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false); - expect(wrapper.find('input#run-on-day + label').text()).toBe( - 'The third Sunday in March' - ); - expect(wrapper.find('input#run-on-last-day').length).toBe(0); - }); test('error shown when end date/time comes before start date/time', async () => { expect(wrapper.find('input#end-never').prop('checked')).toBe(true); expect(wrapper.find('input#end-after').prop('checked')).toBe(false); diff --git a/awx/ui_next/src/util/dates.jsx b/awx/ui_next/src/util/dates.jsx index b605f79abf..68d9461334 100644 --- a/awx/ui_next/src/util/dates.jsx +++ b/awx/ui_next/src/util/dates.jsx @@ -1,5 +1,6 @@ /* eslint-disable import/prefer-default-export */ import { t } from '@lingui/macro'; +import { RRule } from 'rrule'; import { getLanguage } from './language'; const prependZeros = value => value.toString().padStart(2, 0); @@ -28,87 +29,37 @@ export function dateToInputDateTime(dateObj) { return `${year}-${month}-${day}T${hour}:${minute}:${second}`; } -export function getDaysInMonth(dateString) { - const dateObj = new Date(dateString); - return new Date(dateObj.getFullYear(), dateObj.getMonth() + 1, 0).getDate(); -} - -export function getWeekNumber(dateString) { - const dateObj = new Date(dateString); - const dayOfMonth = dateObj.getDate(); - const dayOfWeek = dateObj.getDay(); - if (dayOfMonth < 8) { - return 1; - } - dateObj.setDate(dayOfMonth - dayOfWeek + 1); - return Math.ceil(dayOfMonth / 7); -} - -export function getDayString(dayIndex, i18n) { - switch (dayIndex) { - case 0: - return i18n._(t`Sunday`); - case 1: - return i18n._(t`Monday`); - case 2: - return i18n._(t`Tuesday`); - case 3: - return i18n._(t`Wednesday`); - case 4: - return i18n._(t`Thursday`); - case 5: - return i18n._(t`Friday`); - case 6: - return i18n._(t`Saturday`); +export function getRRuleDayConstants(dayString, i18n) { + switch (dayString) { + case 'sunday': + return RRule.SU; + case 'monday': + return RRule.MO; + case 'tuesday': + return RRule.TU; + case 'wednesday': + return RRule.WE; + case 'thursday': + return RRule.TH; + case 'friday': + return RRule.FR; + case 'saturday': + return RRule.SA; + case 'day': + return [ + RRule.MO, + RRule.TU, + RRule.WE, + RRule.TH, + RRule.FR, + RRule.SA, + RRule.SU, + ]; + case 'weekday': + return [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR]; + case 'weekendDay': + return [RRule.SA, RRule.SU]; default: - throw new Error(i18n._(t`Unrecognized day index`)); - } -} - -export function getWeekString(weekNumber, i18n) { - switch (weekNumber) { - case 1: - return i18n._(t`first`); - case 2: - return i18n._(t`second`); - case 3: - return i18n._(t`third`); - case 4: - return i18n._(t`fourth`); - case 5: - return i18n._(t`fifth`); - default: - throw new Error(i18n._(t`Unrecognized week number`)); - } -} - -export function getMonthString(monthIndex, i18n) { - switch (monthIndex) { - case 0: - return i18n._(t`January`); - case 1: - return i18n._(t`February`); - case 2: - return i18n._(t`March`); - case 3: - return i18n._(t`April`); - case 4: - return i18n._(t`May`); - case 5: - return i18n._(t`June`); - case 6: - return i18n._(t`July`); - case 7: - return i18n._(t`August`); - case 8: - return i18n._(t`September`); - case 9: - return i18n._(t`October`); - case 10: - return i18n._(t`November`); - case 11: - return i18n._(t`December`); - default: - throw new Error(i18n._(t`Unrecognized month index`)); + throw new Error(i18n._(t`Unrecognized day string`)); } } diff --git a/awx/ui_next/src/util/dates.test.jsx b/awx/ui_next/src/util/dates.test.jsx index 4a13701a28..83e6cb066a 100644 --- a/awx/ui_next/src/util/dates.test.jsx +++ b/awx/ui_next/src/util/dates.test.jsx @@ -1,12 +1,9 @@ +import { RRule } from 'rrule'; import { dateToInputDateTime, - getDaysInMonth, - getDayString, - getMonthString, - getWeekNumber, - getWeekString, formatDateString, formatDateStringUTC, + getRRuleDayConstants, secondsToHHMMSS, } from './dates'; @@ -59,63 +56,35 @@ describe('dateToInputDateTime', () => { }); }); -describe('getDaysInMonth', () => { +describe('getRRuleDayConstants', () => { test('it returns the expected value', () => { - expect(getDaysInMonth('2020-02-15T00:00:00Z')).toEqual(29); - expect(getDaysInMonth('2020-03-15T00:00:00Z')).toEqual(31); - expect(getDaysInMonth('2020-04-15T00:00:00Z')).toEqual(30); - }); -}); - -describe('getWeekNumber', () => { - test('it returns the expected value', () => { - expect(getWeekNumber('2020-02-01T00:00:00Z')).toEqual(1); - expect(getWeekNumber('2020-02-08T00:00:00Z')).toEqual(2); - expect(getWeekNumber('2020-02-15T00:00:00Z')).toEqual(3); - expect(getWeekNumber('2020-02-22T00:00:00Z')).toEqual(4); - expect(getWeekNumber('2020-02-29T00:00:00Z')).toEqual(5); - }); -}); - -describe('getDayString', () => { - test('it returns the expected value', () => { - expect(getDayString(0, i18n)).toEqual('Sunday'); - expect(getDayString(1, i18n)).toEqual('Monday'); - expect(getDayString(2, i18n)).toEqual('Tuesday'); - expect(getDayString(3, i18n)).toEqual('Wednesday'); - expect(getDayString(4, i18n)).toEqual('Thursday'); - expect(getDayString(5, i18n)).toEqual('Friday'); - expect(getDayString(6, i18n)).toEqual('Saturday'); - expect(() => getDayString(7, i18n)).toThrow(); - }); -}); - -describe('getWeekString', () => { - test('it returns the expected value', () => { - expect(() => getWeekString(0, i18n)).toThrow(); - expect(getWeekString(1, i18n)).toEqual('first'); - expect(getWeekString(2, i18n)).toEqual('second'); - expect(getWeekString(3, i18n)).toEqual('third'); - expect(getWeekString(4, i18n)).toEqual('fourth'); - expect(getWeekString(5, i18n)).toEqual('fifth'); - expect(() => getWeekString(6, i18n)).toThrow(); - }); -}); - -describe('getMonthString', () => { - test('it returns the expected value', () => { - expect(getMonthString(0, i18n)).toEqual('January'); - expect(getMonthString(1, i18n)).toEqual('February'); - expect(getMonthString(2, i18n)).toEqual('March'); - expect(getMonthString(3, i18n)).toEqual('April'); - expect(getMonthString(4, i18n)).toEqual('May'); - expect(getMonthString(5, i18n)).toEqual('June'); - expect(getMonthString(6, i18n)).toEqual('July'); - expect(getMonthString(7, i18n)).toEqual('August'); - expect(getMonthString(8, i18n)).toEqual('September'); - expect(getMonthString(9, i18n)).toEqual('October'); - expect(getMonthString(10, i18n)).toEqual('November'); - expect(getMonthString(11, i18n)).toEqual('December'); - expect(() => getMonthString(12, i18n)).toThrow(); + expect(getRRuleDayConstants('monday', i18n)).toEqual(RRule.MO); + expect(getRRuleDayConstants('tuesday', i18n)).toEqual(RRule.TU); + expect(getRRuleDayConstants('wednesday', i18n)).toEqual(RRule.WE); + expect(getRRuleDayConstants('thursday', i18n)).toEqual(RRule.TH); + expect(getRRuleDayConstants('friday', i18n)).toEqual(RRule.FR); + expect(getRRuleDayConstants('saturday', i18n)).toEqual(RRule.SA); + expect(getRRuleDayConstants('sunday', i18n)).toEqual(RRule.SU); + expect(getRRuleDayConstants('day', i18n)).toEqual([ + RRule.MO, + RRule.TU, + RRule.WE, + RRule.TH, + RRule.FR, + RRule.SA, + RRule.SU, + ]); + expect(getRRuleDayConstants('weekday', i18n)).toEqual([ + RRule.MO, + RRule.TU, + RRule.WE, + RRule.TH, + RRule.FR, + ]); + expect(getRRuleDayConstants('weekendDay', i18n)).toEqual([ + RRule.SA, + RRule.SU, + ]); + expect(() => getRRuleDayConstants('foobar', i18n)).toThrow(); }); }); From 288ce123ca67def63b887fd4719f54b54545ca5e Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 30 Mar 2020 17:04:17 -0400 Subject: [PATCH 6/7] Adds resources_needed_to_start to the list of keys for error message handling --- awx/ui_next/src/components/FormField/FormSubmitError.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/awx/ui_next/src/components/FormField/FormSubmitError.jsx b/awx/ui_next/src/components/FormField/FormSubmitError.jsx index 65621cf581..c502d5767e 100644 --- a/awx/ui_next/src/components/FormField/FormSubmitError.jsx +++ b/awx/ui_next/src/components/FormField/FormSubmitError.jsx @@ -17,6 +17,8 @@ function FormSubmitError({ error }) { 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); } From 33a699b8ae4d89ad32b886e6023921aabe2b843e Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 31 Mar 2020 10:57:30 -0400 Subject: [PATCH 7/7] Display form errors on new lines if there are multiple --- .../src/components/FormField/FormSubmitError.jsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/awx/ui_next/src/components/FormField/FormSubmitError.jsx b/awx/ui_next/src/components/FormField/FormSubmitError.jsx index c502d5767e..1c90e11da5 100644 --- a/awx/ui_next/src/components/FormField/FormSubmitError.jsx +++ b/awx/ui_next/src/components/FormField/FormSubmitError.jsx @@ -33,7 +33,17 @@ function FormSubmitError({ error }) { return null; } - return ; + return ( +
{msg}
) + : errorMessage + } + /> + ); } export default FormSubmitError;