From d9b613ccb3617835257a168100f4bd0f10731099 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 25 Mar 2020 11:59:57 -0400 Subject: [PATCH] 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/', }, },