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/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/FormField/FormSubmitError.jsx b/awx/ui_next/src/components/FormField/FormSubmitError.jsx index 65621cf581..1c90e11da5 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); } @@ -31,7 +33,17 @@ function FormSubmitError({ error }) { return null; } - return ; + return ( +
{msg}
) + : errorMessage + } + /> + ); } export default FormSubmitError; 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..23f98cd383 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx @@ -0,0 +1,151 @@ +import React, { useState } from 'react'; +import { func } from 'prop-types'; +import { t } from '@lingui/macro'; +import { withI18n } from '@lingui/react'; +import { useHistory, useLocation } from 'react-router-dom'; +import { RRule } from 'rrule'; +import { Card } from '@patternfly/react-core'; +import { CardBody } from '@components/Card'; +import { getRRuleDayConstants } from '@util/dates'; +import ScheduleForm from '../shared/ScheduleForm'; + +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 buildRuleObj = values => { + const [startDate, startTime] = values.startDateTime.split('T'); + // Dates are formatted like "YYYY-MM-DD" + const [startYear, startMonth, startDay] = startDate.split('-'); + // Times are formatted like "HH:MM:SS" or "HH:MM" if no seconds + // have been specified + const [startHour = 0, startMinute = 0, startSecond = 0] = startTime.split( + ':' + ); + + const ruleObj = { + interval: values.interval, + dtstart: new Date( + Date.UTC( + startYear, + parseInt(startMonth, 10) - 1, + startDay, + startHour, + startMinute, + startSecond + ) + ), + tzid: values.timezone, + }; + + switch (values.frequency) { + case 'none': + ruleObj.count = 1; + ruleObj.freq = RRule.MINUTELY; + break; + case 'minute': + ruleObj.freq = RRule.MINUTELY; + break; + case 'hour': + ruleObj.freq = RRule.HOURLY; + break; + case 'day': + ruleObj.freq = RRule.DAILY; + break; + case 'week': + ruleObj.freq = RRule.WEEKLY; + ruleObj.byweekday = values.daysOfWeek.map(day => RRule[day]); + break; + case 'month': + ruleObj.freq = RRule.MONTHLY; + if (values.runOn === 'day') { + ruleObj.bymonthday = values.runOnDayNumber; + } else if (values.runOn === 'the') { + ruleObj.bysetpos = parseInt(values.runOnTheOccurrence, 10); + ruleObj.byweekday = getRRuleDayConstants(values.runOnTheDay, i18n); + } + break; + case 'year': + ruleObj.freq = RRule.YEARLY; + if (values.runOn === 'day') { + ruleObj.bymonth = parseInt(values.runOnDayMonth, 10); + ruleObj.bymonthday = values.runOnDayNumber; + } else if (values.runOn === 'the') { + ruleObj.bysetpos = parseInt(values.runOnTheOccurrence, 10); + ruleObj.byweekday = getRRuleDayConstants(values.runOnTheDay, i18n); + ruleObj.bymonth = parseInt(values.runOnTheMonth, 10); + } + break; + default: + throw new Error(i18n._(t`Frequency did not match an expected value`)); + } + + switch (values.end) { + case 'never': + break; + case 'after': + ruleObj.count = values.occurrences; + break; + case 'onDate': { + const [endDate, endTime] = values.endDateTime.split('T'); + const [endYear, endMonth, endDay] = endDate.split('-'); + const [endHour = 0, endMinute = 0, endSecond = 0] = endTime.split(':'); + ruleObj.until = new Date( + Date.UTC( + endYear, + parseInt(endMonth, 10) - 1, + endDay, + endHour, + endMinute, + endSecond + ) + ); + break; + } + default: + throw new Error(i18n._(t`End did not match an expected value`)); + } + + return ruleObj; + }; + + const handleSubmit = async values => { + try { + const rule = new RRule(buildRuleObj(values)); + const { + 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..8a3376e504 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.jsx @@ -0,0 +1,255 @@ +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, + 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', + 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', + 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, + 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: 'day', + runOnDayNumber: 1, + 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=1', + }); + }); + test('Successfully creates a schedule with monthly repeat frequency on the last tuesday of the month', async () => { + await act(async () => { + wrapper.find('ScheduleForm').invoke('handleSubmit')({ + description: 'test description', + end: 'never', + endDateTime: '2020-03-26T11:00:00', + frequency: 'month', + interval: 1, + name: 'Run monthly on the last Tuesday', + occurrences: 1, + runOn: 'the', + runOnTheDay: 'tuesday', + runOnTheOccurrence: -1, + startDateTime: '2020-03-31T11:00', + timezone: 'America/New_York', + }); + }); + expect(createSchedule).toHaveBeenCalledWith({ + description: 'test description', + name: 'Run monthly on the last Tuesday', + rrule: + 'DTSTART;TZID=America/New_York:20200331T110000 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=TU', + }); + }); + test('Successfully creates a schedule with yearly repeat frequency on the first day of March', async () => { + await act(async () => { + wrapper.find('ScheduleForm').invoke('handleSubmit')({ + description: 'test description', + end: 'never', + frequency: 'year', + interval: 1, + name: 'Yearly on the first day of March', + occurrences: 1, + runOn: 'day', + runOnDayMonth: 3, + runOnDayNumber: 1, + startDateTime: '2020-03-01T00:00', + timezone: 'America/New_York', + }); + }); + expect(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=1', + }); + }); + test('Successfully creates a schedule with yearly repeat frequency on the second Friday in April', async () => { + await act(async () => { + wrapper.find('ScheduleForm').invoke('handleSubmit')({ + description: 'test description', + end: 'never', + frequency: 'year', + interval: 1, + name: 'Yearly on the second Friday in April', + occurrences: 1, + runOn: 'the', + runOnTheOccurrence: 2, + runOnTheDay: 'friday', + runOnTheMonth: 4, + startDateTime: '2020-04-10T11:15', + timezone: 'America/New_York', + }); + }); + expect(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;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/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/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 = 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 [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', + }); + 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] = 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), + }); + + 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]; + 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`)); + } + }; + + /* eslint-disable no-restricted-globals */ + return ( + <> + +
+ { + interval.onChange(event); + }} + /> + {getRunEveryLabel()} +
+
+ {frequency?.value === 'week' && ( + +
+ { + updateDaysOfWeek('SU', checked); + }} + aria-label={i18n._(t`Sunday`)} + id="schedule-days-of-week-sun" + name="daysOfWeek" + /> + { + updateDaysOfWeek('MO', checked); + }} + aria-label={i18n._(t`Monday`)} + id="schedule-days-of-week-mon" + name="daysOfWeek" + /> + { + updateDaysOfWeek('TU', checked); + }} + aria-label={i18n._(t`Tuesday`)} + id="schedule-days-of-week-tue" + name="daysOfWeek" + /> + { + updateDaysOfWeek('WE', checked); + }} + aria-label={i18n._(t`Wednesday`)} + id="schedule-days-of-week-wed" + name="daysOfWeek" + /> + { + updateDaysOfWeek('TH', checked); + }} + aria-label={i18n._(t`Thursday`)} + id="schedule-days-of-week-thu" + name="daysOfWeek" + /> + { + updateDaysOfWeek('FR', checked); + }} + aria-label={i18n._(t`Friday`)} + id="schedule-days-of-week-fri" + name="daysOfWeek" + /> + { + updateDaysOfWeek('SA', checked); + }} + aria-label={i18n._(t`Saturday`)} + id="schedule-days-of-week-sat" + name="daysOfWeek" + /> +
+
+ )} + {(frequency?.value === 'month' || frequency?.value === 'year') && + !isNaN(new Date(startDateTime.value)) && ( + + + {frequency?.value === 'month' && ( + + Day + + )} + {frequency?.value === 'year' && ( + + )} + { + runOnDayNumber.onChange(event); + }} + /> +
+ } + value="day" + isChecked={runOn.value === 'day'} + onChange={(value, event) => { + event.target.value = 'day'; + runOn.onChange(event); + }} + /> + + + The + + + + {frequency?.value === 'year' && ( + + )} + + } + value="the" + isChecked={runOn.value === 'the'} + onChange={(value, event) => { + event.target.value = 'the'; + 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..41f43da561 --- /dev/null +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx @@ -0,0 +1,253 @@ +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 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, + 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 ( + + {() => { + return ( + { + const errors = {}; + const { + end, + endDateTime, + frequency, + runOn, + runOnDayNumber, + 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.` + ); + } + + 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; + }} + > + {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..2487f9beee --- /dev/null +++ b/awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx @@ -0,0 +1,315 @@ +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); + 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(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( + '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); + 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('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' }, + }); + }); + 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('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..68d9461334 100644 --- a/awx/ui_next/src/util/dates.jsx +++ b/awx/ui_next/src/util/dates.jsx @@ -1,6 +1,10 @@ /* 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); + export function formatDateString(dateString, lang = getLanguage(navigator)) { return new Date(dateString).toLocaleString(lang); } @@ -12,3 +16,50 @@ 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 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 string`)); + } +} diff --git a/awx/ui_next/src/util/dates.test.jsx b/awx/ui_next/src/util/dates.test.jsx index 90b02c1185..83e6cb066a 100644 --- a/awx/ui_next/src/util/dates.test.jsx +++ b/awx/ui_next/src/util/dates.test.jsx @@ -1,4 +1,22 @@ -import { formatDateString } from './dates'; +import { RRule } from 'rrule'; +import { + dateToInputDateTime, + formatDateString, + formatDateStringUTC, + getRRuleDayConstants, + 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 +29,62 @@ 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('getRRuleDayConstants', () => { + test('it returns the expected value', () => { + 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(); + }); +}); 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/', }, },