diff --git a/awx/ui/src/components/ErrorDetail/ErrorDetail.js b/awx/ui/src/components/ErrorDetail/ErrorDetail.js index 1ecceea468..4f24e93d62 100644 --- a/awx/ui/src/components/ErrorDetail/ErrorDetail.js +++ b/awx/ui/src/components/ErrorDetail/ErrorDetail.js @@ -24,6 +24,7 @@ const CardBody = styled(PFCardBody)` const Expandable = styled(PFExpandable)` text-align: left; + max-width: 75vw; & .pf-c-expandable__toggle { padding-left: 10px; @@ -54,7 +55,7 @@ function ErrorDetail({ error }) { {response?.config?.method.toUpperCase()} {response?.config?.url}{' '} {response?.status} - + {Array.isArray(message) ? ( {message.map((m) => @@ -70,9 +71,16 @@ function ErrorDetail({ error }) { }; const renderStack = () => ( - - {error.stack} - + <> + + + {error.name}: {error.message} + + + + {error.stack} + + > ); return ( diff --git a/awx/ui/src/components/FormLayout/FormLayout.js b/awx/ui/src/components/FormLayout/FormLayout.js index c9eb849822..16bb4712de 100644 --- a/awx/ui/src/components/FormLayout/FormLayout.js +++ b/awx/ui/src/components/FormLayout/FormLayout.js @@ -10,6 +10,11 @@ export const FormColumnLayout = styled.div` @media (min-width: 1210px) { grid-template-columns: repeat(3, 1fr); } + + ${(props) => + props.stacked && + `border-bottom: 1px solid var(--pf-global--BorderColor--100); + padding: var(--pf-global--spacer--sm) 0 var(--pf-global--spacer--md) `} `; export const FormFullWidthLayout = styled.div` diff --git a/awx/ui/src/components/Schedule/ScheduleAdd/ScheduleAdd.js b/awx/ui/src/components/Schedule/ScheduleAdd/ScheduleAdd.js index 9b1d3cd1fa..61416e3a82 100644 --- a/awx/ui/src/components/Schedule/ScheduleAdd/ScheduleAdd.js +++ b/awx/ui/src/components/Schedule/ScheduleAdd/ScheduleAdd.js @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import { func, shape } from 'prop-types'; import { useHistory, useLocation } from 'react-router-dom'; -import { RRule } from 'rrule'; import { Card } from '@patternfly/react-core'; import yaml from 'js-yaml'; import { parseVariableField } from 'util/yaml'; @@ -12,7 +11,7 @@ import mergeExtraVars from 'util/prompt/mergeExtraVars'; import getSurveyValues from 'util/prompt/getSurveyValues'; import { getAddedAndRemoved } from 'util/lists'; import ScheduleForm from '../shared/ScheduleForm'; -import buildRuleObj from '../shared/buildRuleObj'; +import buildRuleSet from '../shared/buildRuleSet'; import { CardBody } from '../../Card'; function ScheduleAdd({ @@ -36,21 +35,12 @@ function ScheduleAdd({ ) => { const { inventory, - extra_vars, - originalCredentials, - end, frequency, - interval, + frequencyOptions, + exceptionFrequency, + exceptionOptions, timezone, - occurrences, - runOn, - runOnTheDay, - runOnTheMonth, - runOnDayMonth, - runOnDayNumber, - runOnTheOccurrence, credentials, - daysOfWeek, ...submitValues } = values; const { added } = getAddedAndRemoved( @@ -83,11 +73,13 @@ function ScheduleAdd({ } try { - const rule = new RRule(buildRuleObj(values)); + const ruleSet = buildRuleSet(values); const requestData = { ...submitValues, - rrule: rule.toString().replace(/\n/g, ' '), + rrule: ruleSet.toString().replace(/\n/g, ' '), }; + delete requestData.startDate; + delete requestData.startTime; if (Object.keys(values).includes('daysToKeep')) { if (requestData.extra_data) { @@ -98,10 +90,6 @@ function ScheduleAdd({ }); } } - delete requestData.startDate; - delete requestData.startTime; - delete requestData.endDate; - delete requestData.endTime; const { data: { id: scheduleId }, diff --git a/awx/ui/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.js b/awx/ui/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.js index 5ce1f547b7..870fa15edf 100644 --- a/awx/ui/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.js +++ b/awx/ui/src/components/Schedule/ScheduleAdd/ScheduleAdd.test.js @@ -80,9 +80,7 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'never', - frequency: 'none', - interval: 1, + frequency: [], name: 'Run once schedule', startDate: '2020-03-25', startTime: '10:00 AM', @@ -98,15 +96,19 @@ describe('', () => { }); }); - test('Successfully creates a schedule with 10 minute repeat frequency after 10 occurrences', async () => { + test('Successfully creates a schedule with 10 minute repeat frequency and 10 occurrences', async () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'after', - frequency: 'minute', - interval: 10, + frequency: ['minute'], + frequencyOptions: { + minute: { + end: 'after', + interval: 10, + occurrences: 10, + }, + }, name: 'Run every 10 minutes 10 times', - occurrences: 10, startDate: '2020-03-25', startTime: '10:30 AM', timezone: 'America/New_York', @@ -125,11 +127,15 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'onDate', - endDate: '2020-03-26', - endTime: '10:45 AM', - frequency: 'hour', - interval: 1, + frequency: ['hour'], + frequencyOptions: { + hour: { + end: 'onDate', + interval: 1, + endDate: '2020-03-26', + endTime: '10:45 AM', + }, + }, name: 'Run every hour until date', startDate: '2020-03-25', startTime: '10:45 AM', @@ -141,7 +147,7 @@ describe('', () => { name: 'Run every hour until date', extra_data: {}, rrule: - 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=HOURLY;UNTIL=20200326T104500', + 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=HOURLY;UNTIL=20200326T144500Z', }); }); @@ -149,9 +155,13 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'never', - frequency: 'day', - interval: 1, + frequency: ['day'], + frequencyOptions: { + day: { + end: 'never', + interval: 1, + }, + }, name: 'Run daily', startDate: '2020-03-25', startTime: '10:45 AM', @@ -170,13 +180,17 @@ describe('', () => { test('Successfully creates a schedule with weekly repeat frequency on mon/wed/fri', async () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ - daysOfWeek: [RRule.MO, RRule.WE, RRule.FR], description: 'test description', - end: 'never', - frequency: 'week', - interval: 1, + frequency: ['week'], + frequencyOptions: { + week: { + end: 'never', + interval: 1, + occurrences: 1, + daysOfWeek: [RRule.MO, RRule.WE, RRule.FR], + }, + }, name: 'Run weekly on mon/wed/fri', - occurrences: 1, startDate: '2020-03-25', startTime: '10:45 AM', timezone: 'America/New_York', @@ -194,13 +208,17 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'never', - frequency: 'month', - interval: 1, + frequency: ['month'], + frequencyOptions: { + month: { + end: 'never', + occurrences: 1, + interval: 1, + runOn: 'day', + runOnDayNumber: 1, + }, + }, name: 'Run on the first day of the month', - occurrences: 1, - runOn: 'day', - runOnDayNumber: 1, startTime: '10:45 AM', startDate: '2020-04-01', timezone: 'America/New_York', @@ -219,16 +237,20 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'never', - endDate: '2020-03-26', - endTime: '11:00 AM', - frequency: 'month', - interval: 1, + frequency: ['month'], + frequencyOptions: { + month: { + end: 'never', + endDate: '2020-03-26', + endTime: '11:00 AM', + interval: 1, + occurrences: 1, + runOn: 'the', + runOnTheDay: 'tuesday', + runOnTheOccurrence: -1, + }, + }, name: 'Run monthly on the last Tuesday', - occurrences: 1, - runOn: 'the', - runOnTheDay: 'tuesday', - runOnTheOccurrence: -1, startDate: '2020-03-31', startTime: '11:00 AM', timezone: 'America/New_York', @@ -242,18 +264,23 @@ describe('', () => { '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('Formik').invoke('onSubmit')({ description: 'test description', - end: 'never', - frequency: 'year', - interval: 1, + frequency: ['year'], + frequencyOptions: { + year: { + end: 'never', + interval: 1, + occurrences: 1, + runOn: 'day', + runOnDayMonth: 3, + runOnDayNumber: 1, + }, + }, name: 'Yearly on the first day of March', - occurrences: 1, - runOn: 'day', - runOnDayMonth: 3, - runOnDayNumber: 1, startDate: '2020-03-01', startTime: '12:00 AM', timezone: 'America/New_York', @@ -272,15 +299,19 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'never', - frequency: 'year', - interval: 1, + frequency: ['year'], + frequencyOptions: { + year: { + end: 'never', + interval: 1, + occurrences: 1, + runOn: 'the', + runOnTheOccurrence: 2, + runOnTheDay: 'friday', + runOnTheMonth: 4, + }, + }, name: 'Yearly on the second Friday in April', - occurrences: 1, - runOn: 'the', - runOnTheOccurrence: 2, - runOnTheDay: 'friday', - runOnTheMonth: 4, startDate: '2020-04-10', startTime: '11:15 AM', timezone: 'America/New_York', @@ -299,15 +330,19 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'never', - frequency: 'year', - interval: 1, + frequency: ['year'], + frequencyOptions: { + year: { + end: 'never', + interval: 1, + occurrences: 1, + runOn: 'the', + runOnTheOccurrence: 1, + runOnTheDay: 'weekday', + runOnTheMonth: 10, + }, + }, name: 'Yearly on the first weekday in October', - occurrences: 1, - runOn: 'the', - runOnTheOccurrence: 1, - runOnTheDay: 'weekday', - runOnTheMonth: 10, startDate: '2020-04-10', startTime: '11:15 AM', timezone: 'America/New_York', @@ -376,17 +411,7 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ name: 'Schedule', - end: 'never', - endDate: '2021-01-29', - endTime: '2:15 PM', - frequency: 'none', - occurrences: 1, - runOn: 'day', - runOnDayMonth: 1, - runOnDayNumber: 1, - runOnTheDay: 'sunday', - runOnTheMonth: 1, - runOnTheOccurrence: 1, + frequency: [], skip_tags: '', inventory: { name: 'inventory', id: 45 }, credentials: [ @@ -405,7 +430,7 @@ describe('', () => { inventory: 45, name: 'Schedule', rrule: - 'DTSTART;TZID=America/New_York:20210128T141500 RRULE:COUNT=1;FREQ=MINUTELY', + 'DTSTART;TZID=America/New_York:20210128T141500 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', skip_tags: '', }); expect(SchedulesAPI.associateCredential).toBeCalledWith(3, 10); @@ -462,9 +487,7 @@ describe('', () => { await act(async () => { scheduleSurveyWrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'never', - frequency: 'none', - interval: 1, + frequency: [], name: 'Run once schedule', startDate: '2020-03-25', startTime: '10:00 AM', diff --git a/awx/ui/src/components/Schedule/ScheduleDetail/FrequencyDetails.js b/awx/ui/src/components/Schedule/ScheduleDetail/FrequencyDetails.js new file mode 100644 index 0000000000..48f55601ed --- /dev/null +++ b/awx/ui/src/components/Schedule/ScheduleDetail/FrequencyDetails.js @@ -0,0 +1,218 @@ +import React from 'react'; +import styled from 'styled-components'; +import { t, Plural, SelectOrdinal } from '@lingui/macro'; +import { DateTime } from 'luxon'; +import { formatDateString } from 'util/dates'; +import { DetailList, Detail } from '../../DetailList'; + +const Label = styled.div` + margin-bottom: var(--pf-global--spacer--sm); + font-weight: var(--pf-global--FontWeight--bold); +`; + +export default function FrequencyDetails({ type, label, options, timezone }) { + const getRunEveryLabel = () => { + const { interval } = options; + switch (type) { + case 'minute': + return ( + + ); + case 'hour': + return ( + + ); + case 'day': + return ( + + ); + case 'week': + return ( + + ); + case 'month': + return ( + + ); + case 'year': + return ( + + ); + default: + throw new Error(t`Frequency did not match an expected value`); + } + }; + + const weekdays = { + 0: t`Monday`, + 1: t`Tuesday`, + 2: t`Wednesday`, + 3: t`Thursday`, + 4: t`Friday`, + 5: t`Saturday`, + 6: t`Sunday`, + }; + + return ( + + {label} + + + {type === 'week' ? ( + weekdays[d.weekday]) + .join(', ')} + /> + ) : null} + + + + + ); +} + +function sortWeekday(a, b) { + if (a.weekday === 6) return -1; + if (b.weekday === 6) return 1; + return a.weekday - b.weekday; +} + +function RunOnDetail({ type, options }) { + if (type === 'month') { + if (options.runOn === 'day') { + return ( + + ); + } + const dayOfWeek = options.runOnTheDay; + return ( + + ) + } + /> + ); + } + if (type === 'year') { + const months = { + 1: t`January`, + 2: t`February`, + 3: t`March`, + 4: t`April`, + 5: t`May`, + 6: t`June`, + 7: t`July`, + 8: t`August`, + 9: t`September`, + 10: t`October`, + 11: t`November`, + 12: t`December`, + }; + if (options.runOn === 'day') { + return ( + + ); + } + const weekdays = { + sunday: t`Sunday`, + monday: t`Monday`, + tuesday: t`Tuesday`, + wednesday: t`Wednesday`, + thursday: t`Thursday`, + friday: t`Friday`, + saturday: t`Saturday`, + day: t`day`, + weekday: t`weekday`, + weekendDay: t`weekend day`, + }; + const weekday = weekdays[options.runOnTheDay]; + const month = months[options.runOnTheMonth]; + return ( + + ) + } + /> + ); + } + return null; +} + +function getEndValue(type, options, timezone) { + if (options.end === 'never') { + return t`Never`; + } + if (options.end === 'after') { + const numOccurrences = options.occurrences; + return ( + + ); + } + + const date = DateTime.fromFormat( + `${options.endDate} ${options.endTime}`, + 'yyyy-MM-dd h:mm a', + { + zone: timezone, + } + ); + return formatDateString(date, timezone); +} diff --git a/awx/ui/src/components/Schedule/ScheduleDetail/ScheduleDetail.js b/awx/ui/src/components/Schedule/ScheduleDetail/ScheduleDetail.js index 45a1471dde..a337018927 100644 --- a/awx/ui/src/components/Schedule/ScheduleDetail/ScheduleDetail.js +++ b/awx/ui/src/components/Schedule/ScheduleDetail/ScheduleDetail.js @@ -1,7 +1,6 @@ import 'styled-components/macro'; import React, { useCallback, useEffect } from 'react'; import { Link, useHistory, useLocation } from 'react-router-dom'; -import { RRule, rrulestr } from 'rrule'; import styled from 'styled-components'; import { t } from '@lingui/macro'; @@ -12,6 +11,8 @@ import useRequest, { useDismissableError } from 'hooks/useRequest'; import { JobTemplatesAPI, SchedulesAPI, WorkflowJobTemplatesAPI } from 'api'; import { parseVariableField, jsonToYaml } from 'util/yaml'; import { useConfig } from 'contexts/Config'; +import parseRuleObj from '../shared/parseRuleObj'; +import FrequencyDetails from './FrequencyDetails'; import AlertModal from '../../AlertModal'; import { CardBody, CardActionsRow } from '../../Card'; import ContentError from '../../ContentError'; @@ -41,6 +42,26 @@ const PromptTitle = styled(Title)` const PromptDetailList = styled(DetailList)` padding: 0px 20px; `; + +const FrequencyDetailsContainer = styled.div` + background-color: var(--pf-global--palette--black-150); + margin-top: var(--pf-global--spacer--lg); + margin-bottom: var(--pf-global--spacer--lg); + margin-right: calc(var(--pf-c-card--child--PaddingRight) * -1); + margin-left: calc(var(--pf-c-card--child--PaddingLeft) * -1); + padding: var(--pf-c-card--child--PaddingRight); + + & > p { + margin-bottom: var(--pf-global--spacer--md); + } + + & > *:not(:first-child):not(:last-child) { + margin-bottom: var(--pf-global--spacer--md); + padding-bottom: var(--pf-global--spacer--md); + border-bottom: 1px solid var(--pf-global--palette--black-300); + } +`; + function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) { const { id, @@ -132,19 +153,18 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) { fetchCredentialsAndPreview(); }, [fetchCredentialsAndPreview]); - const rule = rrulestr(rrule); - let repeatFrequency = - rule.options.freq === RRule.MINUTELY && dtstart === dtend - ? t`None (Run Once)` - : rule.toText().replace(/^\w/, (c) => c.toUpperCase()); - // We should allow rrule tot handle this issue, and they have in version 2.6.8. - // (https://github.com/jakubroztocil/rrule/commit/ab9c564a83de2f9688d6671f2a6df273ceb902bf) - // However, we are unable to upgrade to that version because that - // version throws and unexpected warning. - // (https://github.com/jakubroztocil/rrule/issues/427) - if (repeatFrequency.split(' ')[1] === 'minutes') { - repeatFrequency = t`Every minute for ${rule.options.count} times`; - } + const frequencies = { + minute: t`Minute`, + hour: t`Hour`, + day: t`Day`, + week: t`Week`, + month: t`Month`, + year: t`Year`, + }; + const { frequency, frequencyOptions } = parseRuleObj(schedule); + const repeatFrequency = frequency.length + ? frequency.map((f) => frequencies[f]).join(', ') + : t`None (Run Once)`; const { ask_credential_on_launch, @@ -268,6 +288,24 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) { helpText={helpText.localTimeZone(config)} /> + + {frequency.length ? ( + + + {t`Frequency Details`} + + {frequency.map((freq) => ( + + ))} + + ) : null} + {hasDaysToKeepField ? ( ) : null} diff --git a/awx/ui/src/components/Schedule/ScheduleEdit/ScheduleEdit.js b/awx/ui/src/components/Schedule/ScheduleEdit/ScheduleEdit.js index 59b1550a45..143a428de0 100644 --- a/awx/ui/src/components/Schedule/ScheduleEdit/ScheduleEdit.js +++ b/awx/ui/src/components/Schedule/ScheduleEdit/ScheduleEdit.js @@ -1,7 +1,6 @@ import React, { useState } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; -import { RRule } from 'rrule'; import { shape } from 'prop-types'; import { Card } from '@patternfly/react-core'; import yaml from 'js-yaml'; @@ -12,7 +11,7 @@ import { parseVariableField } from 'util/yaml'; import mergeExtraVars from 'util/prompt/mergeExtraVars'; import getSurveyValues from 'util/prompt/getSurveyValues'; import ScheduleForm from '../shared/ScheduleForm'; -import buildRuleObj from '../shared/buildRuleObj'; +import buildRuleSet from '../shared/buildRuleSet'; import { CardBody } from '../../Card'; function ScheduleEdit({ @@ -27,7 +26,7 @@ function ScheduleEdit({ const history = useHistory(); const location = useLocation(); const { pathname } = location; - const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); + const pathRoot = pathname.substring(0, pathname.indexOf('schedules')); const handleSubmit = async ( values, @@ -38,18 +37,11 @@ function ScheduleEdit({ const { inventory, credentials = [], - end, frequency, - interval, + frequencyOptions, + exceptionFrequency, + exceptionOptions, timezone, - occurences, - runOn, - runOnTheDay, - runOnTheMonth, - runOnDayMonth, - runOnDayNumber, - runOnTheOccurence, - daysOfWeek, ...submitValues } = values; const { added, removed } = getAddedAndRemoved( @@ -91,15 +83,13 @@ function ScheduleEdit({ } try { - const rule = new RRule(buildRuleObj(values)); + const ruleSet = buildRuleSet(values); const requestData = { ...submitValues, - rrule: rule.toString().replace(/\n/g, ' '), + rrule: ruleSet.toString().replace(/\n/g, ' '), }; delete requestData.startDate; delete requestData.startTime; - delete requestData.endDate; - delete requestData.endTime; if (Object.keys(values).includes('daysToKeep')) { if (!requestData.extra_data) { diff --git a/awx/ui/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.js b/awx/ui/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.js index 8475bd3349..f5c6eb5aec 100644 --- a/awx/ui/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.js +++ b/awx/ui/src/components/Schedule/ScheduleEdit/ScheduleEdit.test.js @@ -195,9 +195,7 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'never', - frequency: 'none', - interval: 1, + frequency: [], name: 'Run once schedule', startDate: '2020-03-25', startTime: '10:00 AM', @@ -218,11 +216,15 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'after', - frequency: 'minute', - interval: 10, + frequency: ['minute'], + frequencyOptions: { + minute: { + end: 'after', + interval: 10, + occurrences: 10, + }, + }, name: 'Run every 10 minutes 10 times', - occurrences: 10, startDate: '2020-03-25', startTime: '10:30 AM', timezone: 'America/New_York', @@ -232,7 +234,6 @@ describe('', () => { description: 'test description', name: 'Run every 10 minutes 10 times', extra_data: {}, - occurrences: 10, rrule: 'DTSTART;TZID=America/New_York:20200325T103000 RRULE:INTERVAL=10;FREQ=MINUTELY;COUNT=10', }); @@ -242,11 +243,15 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'onDate', - endDate: '2020-03-26', - endTime: '10:45 AM', - frequency: 'hour', - interval: 1, + frequency: ['hour'], + frequencyOptions: { + hour: { + end: 'onDate', + endDate: '2020-03-26', + endTime: '10:45 AM', + interval: 1, + }, + }, name: 'Run every hour until date', startDate: '2020-03-25', startTime: '10:45 AM', @@ -259,7 +264,7 @@ describe('', () => { name: 'Run every hour until date', extra_data: {}, rrule: - 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=HOURLY;UNTIL=20200326T104500', + 'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=HOURLY;UNTIL=20200326T144500Z', }); }); @@ -267,9 +272,13 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'never', - frequency: 'day', - interval: 1, + frequency: ['day'], + frequencyOptions: { + day: { + end: 'never', + interval: 1, + }, + }, name: 'Run daily', startDate: '2020-03-25', startTime: '10:45 AM', @@ -284,16 +293,21 @@ describe('', () => { '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('Formik').invoke('onSubmit')({ - daysOfWeek: [RRule.MO, RRule.WE, RRule.FR], description: 'test description', - end: 'never', - frequency: 'week', - interval: 1, + frequency: ['week'], + frequencyOptions: { + week: { + end: 'never', + daysOfWeek: [RRule.MO, RRule.WE, RRule.FR], + interval: 1, + occurrences: 1, + }, + }, name: 'Run weekly on mon/wed/fri', - occurrences: 1, startDate: '2020-03-25', startTime: '10:45 AM', timezone: 'America/New_York', @@ -303,7 +317,6 @@ describe('', () => { description: 'test description', name: 'Run weekly on mon/wed/fri', extra_data: {}, - occurrences: 1, rrule: `DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=${RRule.MO},${RRule.WE},${RRule.FR}`, }); }); @@ -312,13 +325,17 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'never', - frequency: 'month', - interval: 1, + frequency: ['month'], + frequencyOptions: { + month: { + end: 'never', + interval: 1, + occurrences: 1, + runOn: 'day', + runOnDayNumber: 1, + }, + }, name: 'Run on the first day of the month', - occurrences: 1, - runOn: 'day', - runOnDayNumber: 1, startDate: '2020-04-01', startTime: '10:45 AM', timezone: 'America/New_York', @@ -328,7 +345,6 @@ describe('', () => { description: 'test description', name: 'Run on the first day of the month', extra_data: {}, - occurrences: 1, rrule: 'DTSTART;TZID=America/New_York:20200401T104500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYMONTHDAY=1', }); @@ -338,15 +354,20 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'never', - endDateTime: '2020-03-26T11:00:00', - frequency: 'month', - interval: 1, + frequency: ['month'], + frequencyOptions: { + month: { + end: 'never', + endDate: '2020-03-26', + endTime: '11:00 AM', + interval: 1, + occurrences: 1, + runOn: 'the', + runOnTheDay: 'tuesday', + runOnTheOccurrence: -1, + }, + }, name: 'Run monthly on the last Tuesday', - occurrences: 1, - runOn: 'the', - runOnTheDay: 'tuesday', - runOnTheOccurrence: -1, startDate: '2020-03-31', startTime: '11:00 AM', timezone: 'America/New_York', @@ -354,11 +375,8 @@ describe('', () => { }); expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { description: 'test description', - endDateTime: '2020-03-26T11:00:00', name: 'Run monthly on the last Tuesday', extra_data: {}, - occurrences: 1, - runOnTheOccurrence: -1, rrule: 'DTSTART;TZID=America/New_York:20200331T110000 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=TU', }); @@ -368,14 +386,18 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'never', - frequency: 'year', - interval: 1, + frequency: ['year'], + frequencyOptions: { + year: { + end: 'never', + interval: 1, + occurrences: 1, + runOn: 'day', + runOnDayMonth: 3, + runOnDayNumber: 1, + }, + }, name: 'Yearly on the first day of March', - occurrences: 1, - runOn: 'day', - runOnDayMonth: 3, - runOnDayNumber: 1, startTime: '12:00 AM', startDate: '2020-03-01', timezone: 'America/New_York', @@ -385,7 +407,6 @@ describe('', () => { description: 'test description', name: 'Yearly on the first day of March', extra_data: {}, - occurrences: 1, rrule: 'DTSTART;TZID=America/New_York:20200301T000000 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=1', }); @@ -395,15 +416,19 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'never', - frequency: 'year', - interval: 1, + frequency: ['year'], + frequencyOptions: { + year: { + end: 'never', + interval: 1, + occurrences: 1, + runOn: 'the', + runOnTheOccurrence: 2, + runOnTheDay: 'friday', + runOnTheMonth: 4, + }, + }, name: 'Yearly on the second Friday in April', - occurrences: 1, - runOn: 'the', - runOnTheOccurrence: 2, - runOnTheDay: 'friday', - runOnTheMonth: 4, startTime: '11:15 AM', startDate: '2020-04-10', timezone: 'America/New_York', @@ -413,8 +438,6 @@ describe('', () => { description: 'test description', name: 'Yearly on the second Friday in April', extra_data: {}, - occurrences: 1, - runOnTheOccurrence: 2, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=2;BYDAY=FR;BYMONTH=4', }); @@ -424,15 +447,19 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'never', - frequency: 'year', - interval: 1, + frequency: ['year'], + frequencyOptions: { + year: { + end: 'never', + interval: 1, + occurrences: 1, + runOn: 'the', + runOnTheOccurrence: 1, + runOnTheDay: 'weekday', + runOnTheMonth: 10, + }, + }, name: 'Yearly on the first weekday in October', - occurrences: 1, - runOn: 'the', - runOnTheOccurrence: 1, - runOnTheDay: 'weekday', - runOnTheMonth: 10, startTime: '11:15 AM', startDate: '2020-04-10', timezone: 'America/New_York', @@ -442,8 +469,6 @@ describe('', () => { description: 'test description', name: 'Yearly on the first weekday in October', extra_data: {}, - occurrences: 1, - runOnTheOccurrence: 1, rrule: 'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=1;BYDAY=MO,TU,WE,TH,FR;BYMONTH=10', }); @@ -522,17 +547,7 @@ describe('', () => { await act(async () => { wrapper.find('Formik').invoke('onSubmit')({ name: mockSchedule.name, - end: 'never', - endDate: '2021-01-29', - endTime: '2:15 PM', - frequency: 'none', - occurrences: 1, - runOn: 'day', - runOnDayMonth: 1, - runOnDayNumber: 1, - runOnTheDay: 'sunday', - runOnTheMonth: 1, - runOnTheOccurrence: 1, + frequency: [], skip_tags: '', startDate: '2021-01-28', startTime: '2:15 PM', @@ -549,10 +564,8 @@ describe('', () => { expect(SchedulesAPI.update).toBeCalledWith(27, { extra_data: {}, name: 'mock schedule', - occurrences: 1, - runOnTheOccurrence: 1, rrule: - 'DTSTART;TZID=America/New_York:20210128T141500 RRULE:COUNT=1;FREQ=MINUTELY', + 'DTSTART;TZID=America/New_York:20210128T141500 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', skip_tags: '', }); expect(SchedulesAPI.disassociateCredential).toBeCalledWith(27, 75); @@ -621,8 +634,6 @@ describe('', () => { startDateTime: undefined, description: '', extra_data: {}, - occurrences: 1, - runOnTheOccurrence: 1, name: 'foo', inventory: 702, rrule: @@ -723,9 +734,8 @@ describe('', () => { await act(async () => { scheduleSurveyWrapper.find('Formik').invoke('onSubmit')({ description: 'test description', - end: 'never', - frequency: 'none', - interval: 1, + frequency: [], + frequencyOptions: {}, name: 'Run once schedule', startDate: '2020-03-25', startTime: '10:00 AM', diff --git a/awx/ui/src/components/Schedule/shared/DateTimePicker.js b/awx/ui/src/components/Schedule/shared/DateTimePicker.js index 6d958bf50c..44d1f10c13 100644 --- a/awx/ui/src/components/Schedule/shared/DateTimePicker.js +++ b/awx/ui/src/components/Schedule/shared/DateTimePicker.js @@ -16,11 +16,11 @@ const DateTimeGroup = styled.span` `; function DateTimePicker({ dateFieldName, timeFieldName, label }) { const [dateField, dateMeta, dateHelpers] = useField({ - name: `${dateFieldName}`, + name: dateFieldName, validate: combine([required(null), isValidDate]), }); const [timeField, timeMeta, timeHelpers] = useField({ - name: `${timeFieldName}`, + name: timeFieldName, validate: combine([required(null), validateTime()]), }); diff --git a/awx/ui/src/components/Schedule/shared/FrequencyDetailSubform.js b/awx/ui/src/components/Schedule/shared/FrequencyDetailSubform.js index 0c5ec560f6..69b54ab154 100644 --- a/awx/ui/src/components/Schedule/shared/FrequencyDetailSubform.js +++ b/awx/ui/src/components/Schedule/shared/FrequencyDetailSubform.js @@ -11,7 +11,7 @@ import { Radio, TextInput, } from '@patternfly/react-core'; -import { required } from 'util/validators'; +import { required, requiredPositiveInteger } from 'util/validators'; import AnsibleSelect from '../../AnsibleSelect'; import FormField from '../../FormField'; import DateTimePicker from './DateTimePicker'; @@ -45,65 +45,50 @@ const Checkbox = styled(_Checkbox)` } `; -export function requiredPositiveInteger() { - return (value) => { - if (typeof value === 'number') { - if (!Number.isInteger(value)) { - return t`This field must be an integer`; - } - if (value < 1) { - return t`This field must be greater than 0`; - } - } - if (!value) { - return t`Select a value for this field`; - } - return undefined; - }; -} - -const FrequencyDetailSubform = () => { +const FrequencyDetailSubform = ({ frequency, prefix }) => { + const id = prefix.replace('.', '-'); const [runOnDayMonth] = useField({ - name: 'runOnDayMonth', + name: `${prefix}.runOnDayMonth`, }); const [runOnDayNumber] = useField({ - name: 'runOnDayNumber', + name: `${prefix}.runOnDayNumber`, }); const [runOnTheOccurrence] = useField({ - name: 'runOnTheOccurrence', + name: `${prefix}.runOnTheOccurrence`, }); const [runOnTheDay] = useField({ - name: 'runOnTheDay', + name: `${prefix}.runOnTheDay`, }); const [runOnTheMonth] = useField({ - name: 'runOnTheMonth', + name: `${prefix}.runOnTheMonth`, }); - const [startDate] = useField('startDate'); - const [{ name: dateFieldName }] = useField('endDate'); - const [{ name: timeFieldName }] = useField('endTime'); + const [startDate] = useField(`${prefix}.startDate`); const [daysOfWeek, daysOfWeekMeta, daysOfWeekHelpers] = useField({ - name: 'daysOfWeek', - validate: required(t`Select a value for this field`), + name: `${prefix}.daysOfWeek`, + validate: (val) => { + if (frequency === 'week') { + return required(t`Select a value for this field`)(val?.length > 0); + } + return undefined; + }, }); const [end, endMeta] = useField({ - name: 'end', + name: `${prefix}.end`, validate: required(t`Select a value for this field`), }); const [interval, intervalMeta] = useField({ - name: 'interval', + name: `${prefix}.interval`, validate: requiredPositiveInteger(), }); const [runOn, runOnMeta] = useField({ - name: 'runOn', - validate: required(t`Select a value for this field`), - }); - const [frequency] = useField({ - name: 'frequency', - }); - useField({ - name: 'occurrences', - validate: requiredPositiveInteger(), + name: `${prefix}.runOn`, + validate: (val) => { + if (frequency === 'month' || frequency === 'year') { + return required(t`Select a value for this field`)(val); + } + return undefined; + }, }); const monthOptions = [ @@ -170,7 +155,8 @@ const FrequencyDetailSubform = () => { ]; const updateDaysOfWeek = (day, checked) => { - const newDaysOfWeek = [...daysOfWeek.value]; + const newDaysOfWeek = daysOfWeek.value ? [...daysOfWeek.value] : []; + daysOfWeekHelpers.setTouched(true); if (checked) { newDaysOfWeek.push(day); daysOfWeekHelpers.setValue(newDaysOfWeek); @@ -181,10 +167,29 @@ const FrequencyDetailSubform = () => { } }; + const getPeriodLabel = () => { + switch (frequency) { + case 'minute': + return t`Minute`; + case 'hour': + return t`Hour`; + case 'day': + return t`Day`; + case 'week': + return t`Week`; + case 'month': + return t`Month`; + case 'year': + return t`Year`; + default: + throw new Error(t`Frequency did not match an expected value`); + } + }; + const getRunEveryLabel = () => { const intervalValue = interval.value; - switch (frequency.value) { + switch (frequency) { case 'minute': return ; case 'hour': @@ -202,12 +207,14 @@ const FrequencyDetailSubform = () => { } }; - /* eslint-disable no-restricted-globals */ return ( <> + + {getPeriodLabel()} + { { {getRunEveryLabel()} - {frequency?.value === 'week' && ( + {frequency === 'week' && ( { { updateDaysOfWeek(RRule.SU, checked); }} aria-label={t`Sunday`} - id="schedule-days-of-week-sun" - ouiaId="schedule-days-of-week-sun" - name="daysOfWeek" + id={`schedule-days-of-week-sun-${id}`} + ouiaId={`schedule-days-of-week-sun-${id}`} + name={`${prefix}.daysOfWeek`} /> { updateDaysOfWeek(RRule.MO, checked); }} aria-label={t`Monday`} - id="schedule-days-of-week-mon" - ouiaId="schedule-days-of-week-mon" - name="daysOfWeek" + id={`schedule-days-of-week-mon-${id}`} + ouiaId={`schedule-days-of-week-mon-${id}`} + name={`${prefix}.daysOfWeek`} /> { updateDaysOfWeek(RRule.TU, checked); }} aria-label={t`Tuesday`} - id="schedule-days-of-week-tue" - ouiaId="schedule-days-of-week-tue" - name="daysOfWeek" + id={`schedule-days-of-week-tue-${id}`} + ouiaId={`schedule-days-of-week-tue-${id}`} + name={`${prefix}.daysOfWeek`} /> { updateDaysOfWeek(RRule.WE, checked); }} aria-label={t`Wednesday`} - id="schedule-days-of-week-wed" - ouiaId="schedule-days-of-week-wed" - name="daysOfWeek" + id={`schedule-days-of-week-wed-${id}`} + ouiaId={`schedule-days-of-week-wed-${id}`} + name={`${prefix}.daysOfWeek`} /> { updateDaysOfWeek(RRule.TH, checked); }} aria-label={t`Thursday`} - id="schedule-days-of-week-thu" - ouiaId="schedule-days-of-week-thu" - name="daysOfWeek" + id={`schedule-days-of-week-thu-${id}`} + ouiaId={`schedule-days-of-week-thu-${id}`} + name={`${prefix}.daysOfWeek`} /> { updateDaysOfWeek(RRule.FR, checked); }} aria-label={t`Friday`} - id="schedule-days-of-week-fri" - ouiaId="schedule-days-of-week-fri" - name="daysOfWeek" + id={`schedule-days-of-week-fri-${id}`} + ouiaId={`schedule-days-of-week-fri-${id}`} + name={`${prefix}.daysOfWeek`} /> { updateDaysOfWeek(RRule.SA, checked); }} aria-label={t`Saturday`} - id="schedule-days-of-week-sat" - ouiaId="schedule-days-of-week-sat" - name="daysOfWeek" + id={`schedule-days-of-week-sat-${id}`} + ouiaId={`schedule-days-of-week-sat-${id}`} + name={`${prefix}.daysOfWeek`} /> )} - {(frequency?.value === 'month' || frequency?.value === 'year') && - !isNaN(new Date(startDate.value)) && ( + {(frequency === 'month' || frequency === 'year') && + !Number.isNaN(new Date(startDate.value)) && ( { label={t`Run on`} > - {frequency?.value === 'month' && ( + {frequency === 'month' && ( { Day )} - {frequency?.value === 'year' && ( + {frequency === 'year' && ( { /> )} { }} /> The { {...runOnTheOccurrence} /> { ]} {...runOnTheDay} /> - {frequency?.value === 'year' && ( + {frequency === 'year' && ( <> of { )} { event.target.value = 'never'; end.onChange(event); }} - ouiaId="end-never-radio-button" + ouiaId={`end-never-radio-button-${id}`} /> { event.target.value = 'after'; end.onChange(event); }} - ouiaId="end-after-radio-button" + ouiaId={`end-after-radio-button-${id}`} /> { event.target.value = 'onDate'; end.onChange(event); }} - ouiaId="end-on-radio-button" + ouiaId={`end-on-radio-button-${id}`} /> {end?.value === 'after' && ( )} {end?.value === 'onDate' && ( )} diff --git a/awx/ui/src/components/Schedule/shared/FrequencySelect.js b/awx/ui/src/components/Schedule/shared/FrequencySelect.js new file mode 100644 index 0000000000..b76d6357aa --- /dev/null +++ b/awx/ui/src/components/Schedule/shared/FrequencySelect.js @@ -0,0 +1,55 @@ +import React, { useState } from 'react'; +import { arrayOf, string } from 'prop-types'; +import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; + +export default function FrequencySelect({ + id, + value, + onChange, + onBlur, + placeholderText, + children, +}) { + const [isOpen, setIsOpen] = useState(false); + + const onSelect = (event, selectedValue) => { + if (selectedValue === 'none') { + onChange([]); + setIsOpen(false); + return; + } + const index = value.indexOf(selectedValue); + if (index === -1) { + onChange(value.concat(selectedValue)); + } else { + onChange(value.slice(0, index).concat(value.slice(index + 1))); + } + }; + + const onToggle = (val) => { + if (!val) { + onBlur(); + } + setIsOpen(val); + }; + + return ( + + {children} + + ); +} + +FrequencySelect.propTypes = { + value: arrayOf(string).isRequired, +}; + +export { SelectOption, SelectVariant }; diff --git a/awx/ui/src/components/Schedule/shared/ScheduleForm.js b/awx/ui/src/components/Schedule/shared/ScheduleForm.js index ee2b66c813..c29a927868 100644 --- a/awx/ui/src/components/Schedule/shared/ScheduleForm.js +++ b/awx/ui/src/components/Schedule/shared/ScheduleForm.js @@ -3,38 +3,23 @@ import { shape, func } from 'prop-types'; import { DateTime } from 'luxon'; import { t } from '@lingui/macro'; -import { Formik, useField } from 'formik'; +import { Formik } from 'formik'; import { RRule } from 'rrule'; -import { - Button, - Form, - FormGroup, - Title, - ActionGroup, - // To be removed once UI completes complex schedules - Alert, -} from '@patternfly/react-core'; -import { Config, useConfig } from 'contexts/Config'; +import { Button, Form, ActionGroup } from '@patternfly/react-core'; +import { Config } from 'contexts/Config'; import { SchedulesAPI } from 'api'; import { dateToInputDateTime } from 'util/dates'; import useRequest from 'hooks/useRequest'; -import { required } from 'util/validators'; import { parseVariableField } from 'util/yaml'; -import Popover from '../../Popover'; -import AnsibleSelect from '../../AnsibleSelect'; import ContentError from '../../ContentError'; import ContentLoading from '../../ContentLoading'; -import FormField, { FormSubmitError } from '../../FormField'; -import { - FormColumnLayout, - SubFormLayout, - FormFullWidthLayout, -} from '../../FormLayout'; -import FrequencyDetailSubform from './FrequencyDetailSubform'; +import { FormSubmitError } from '../../FormField'; +import { FormColumnLayout, FormFullWidthLayout } from '../../FormLayout'; import SchedulePromptableFields from './SchedulePromptableFields'; -import DateTimePicker from './DateTimePicker'; +import ScheduleFormFields from './ScheduleFormFields'; +import UnsupportedScheduleForm from './UnsupportedScheduleForm'; +import parseRuleObj, { UnsupportedRRuleError } from './parseRuleObj'; import buildRuleObj from './buildRuleObj'; -import helpText from '../../../screens/Template/shared/JobTemplate.helptext'; const NUM_DAYS_PER_FREQUENCY = { week: 7, @@ -42,173 +27,6 @@ const NUM_DAYS_PER_FREQUENCY = { year: 365, }; -const generateRunOnTheDay = (days = []) => { - if ( - [ - RRule.MO, - RRule.TU, - RRule.WE, - RRule.TH, - RRule.FR, - RRule.SA, - RRule.SU, - ].every((element) => days.indexOf(element) > -1) - ) { - return 'day'; - } - if ( - [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR].every( - (element) => days.indexOf(element) > -1 - ) - ) { - return 'weekday'; - } - if ([RRule.SA, RRule.SU].every((element) => days.indexOf(element) > -1)) { - return 'weekendDay'; - } - if (days.indexOf(RRule.MO) > -1) { - return 'monday'; - } - if (days.indexOf(RRule.TU) > -1) { - return 'tuesday'; - } - if (days.indexOf(RRule.WE) > -1) { - return 'wednesday'; - } - if (days.indexOf(RRule.TH) > -1) { - return 'thursday'; - } - if (days.indexOf(RRule.FR) > -1) { - return 'friday'; - } - if (days.indexOf(RRule.SA) > -1) { - return 'saturday'; - } - if (days.indexOf(RRule.SU) > -1) { - return 'sunday'; - } - - return null; -}; - -function ScheduleFormFields({ hasDaysToKeepField, zoneOptions, zoneLinks }) { - const [timezone, timezoneMeta] = useField({ - name: 'timezone', - validate: required(t`Select a value for this field`), - }); - const [frequency, frequencyMeta] = useField({ - name: 'frequency', - validate: required(t`Select a value for this field`), - }); - const [{ name: dateFieldName }] = useField('startDate'); - const [{ name: timeFieldName }] = useField('startTime'); - const [timezoneMessage, setTimezoneMessage] = useState(''); - const warnLinkedTZ = (event, selectedValue) => { - if (zoneLinks[selectedValue]) { - setTimezoneMessage( - `Warning: ${selectedValue} is a link to ${zoneLinks[selectedValue]} and will be saved as that.` - ); - } else { - setTimezoneMessage(''); - } - timezone.onChange(event, selectedValue); - }; - - let timezoneValidatedStatus = 'default'; - if (timezoneMeta.touched && timezoneMeta.error) { - timezoneValidatedStatus = 'error'; - } else if (timezoneMessage) { - timezoneValidatedStatus = 'warning'; - } - - const config = useConfig(); - - return ( - <> - - - - } - > - - - - - - {hasDaysToKeepField ? ( - - ) : null} - {frequency.value !== 'none' && ( - - - {t`Frequency Details`} - - - - - - )} - > - ); -} - function ScheduleForm({ hasDaysToKeepField, handleCancel, @@ -415,25 +233,72 @@ function ScheduleForm({ const [currentDate, time] = dateToInputDateTime(closestQuarterHour.toISO()); const [tomorrowDate] = dateToInputDateTime(tomorrow.toISO()); + const initialFrequencyOptions = { + minute: { + interval: 1, + end: 'never', + occurrences: 1, + endDate: tomorrowDate, + endTime: time, + }, + hour: { + interval: 1, + end: 'never', + occurrences: 1, + endDate: tomorrowDate, + endTime: time, + }, + day: { + interval: 1, + end: 'never', + occurrences: 1, + endDate: tomorrowDate, + endTime: time, + }, + week: { + interval: 1, + end: 'never', + occurrences: 1, + endDate: tomorrowDate, + endTime: time, + daysOfWeek: [], + }, + month: { + interval: 1, + end: 'never', + occurrences: 1, + endDate: tomorrowDate, + endTime: time, + runOn: 'day', + runOnTheOccurrence: 1, + runOnTheDay: 'sunday', + runOnDayNumber: 1, + }, + year: { + interval: 1, + end: 'never', + occurrences: 1, + endDate: tomorrowDate, + endTime: time, + runOn: 'day', + runOnTheOccurrence: 1, + runOnTheDay: 'sunday', + runOnTheMonth: 1, + runOnDayMonth: 1, + runOnDayNumber: 1, + }, + }; + const initialValues = { - daysOfWeek: [], description: schedule.description || '', - end: 'never', - endDate: tomorrowDate, - endTime: time, - frequency: 'none', - interval: 1, + frequency: [], + exceptionFrequency: [], + frequencyOptions: initialFrequencyOptions, + exceptionOptions: initialFrequencyOptions, name: schedule.name || '', - occurrences: 1, - runOn: 'day', - runOnDayMonth: 1, - runOnDayNumber: 1, - runOnTheDay: 'sunday', - runOnTheMonth: 1, - runOnTheOccurrence: 1, startDate: currentDate, startTime: time, - timezone: schedule.timezone || 'America/New_York', + timezone: schedule.timezone || now.zoneName, }; const submitSchedule = ( values, @@ -465,132 +330,23 @@ function ScheduleForm({ initialValues.daysToKeep = initialDaysToKeep; } - const overriddenValues = {}; - - if (Object.keys(schedule).length > 0) { - if (schedule.rrule) { - if (schedule.rrule.split(/\s+/).length > 2) { + let overriddenValues = {}; + if (schedule.rrule) { + try { + overriddenValues = parseRuleObj(schedule); + } catch (error) { + if (error instanceof UnsupportedRRuleError) { return ( - - - {t`Schedule Rules`}: - - {schedule.rrule} - - - - {t`Cancel`} - - - + ); } - - try { - const { - origOptions: { - bymonth, - bymonthday, - bysetpos, - byweekday, - count, - dtstart, - freq, - interval, - }, - } = RRule.fromString(schedule.rrule.replace(' ', '\n')); - - if (dtstart) { - const [startDate, startTime] = dateToInputDateTime( - schedule.dtstart, - schedule.timezone - ); - - overriddenValues.startDate = startDate; - overriddenValues.startTime = startTime; - } - - if (schedule.until) { - overriddenValues.end = 'onDate'; - - const [endDate, endTime] = dateToInputDateTime( - schedule.until, - schedule.timezone - ); - - overriddenValues.endDate = endDate; - overriddenValues.endTime = endTime; - } else if (count) { - overriddenValues.end = 'after'; - overriddenValues.occurrences = count; - } - - if (interval) { - overriddenValues.interval = interval; - } - - if (typeof freq === 'number') { - switch (freq) { - case RRule.MINUTELY: - if (schedule.dtstart !== schedule.dtend) { - overriddenValues.frequency = 'minute'; - } - break; - case RRule.HOURLY: - overriddenValues.frequency = 'hour'; - break; - case RRule.DAILY: - overriddenValues.frequency = 'day'; - break; - case RRule.WEEKLY: - overriddenValues.frequency = 'week'; - if (byweekday) { - overriddenValues.daysOfWeek = byweekday; - } - break; - case RRule.MONTHLY: - overriddenValues.frequency = 'month'; - if (bymonthday) { - overriddenValues.runOnDayNumber = bymonthday; - } else if (bysetpos) { - overriddenValues.runOn = 'the'; - overriddenValues.runOnTheOccurrence = bysetpos; - overriddenValues.runOnTheDay = generateRunOnTheDay(byweekday); - } - break; - case RRule.YEARLY: - overriddenValues.frequency = 'year'; - if (bymonthday) { - overriddenValues.runOnDayNumber = bymonthday; - overriddenValues.runOnDayMonth = bymonth; - } else if (bysetpos) { - overriddenValues.runOn = 'the'; - overriddenValues.runOnTheOccurrence = bysetpos; - overriddenValues.runOnTheDay = generateRunOnTheDay(byweekday); - overriddenValues.runOnTheMonth = bymonth; - } - break; - default: - break; - } - } - } catch (error) { - rruleError = error; - } - } else { - rruleError = new Error(t`Schedule is missing rrule`); + rruleError = error; } + } else if (schedule.id) { + rruleError = new Error(t`Schedule is missing rrule`); } if (contentError || rruleError) { @@ -601,54 +357,83 @@ function ScheduleForm({ return ; } + const validate = (values) => { + const errors = {}; + + values.frequency.forEach((freq) => { + const options = values.frequencyOptions[freq]; + const freqErrors = {}; + + if ( + (freq === 'month' || freq === 'year') && + options.runOn === 'day' && + (options.runOnDayNumber < 1 || options.runOnDayNumber > 31) + ) { + freqErrors.runOn = t`Please select a day number between 1 and 31.`; + } + + if (options.end === 'after' && !options.occurrences) { + freqErrors.occurrences = t`Please enter a number of occurrences.`; + } + + if (options.end === 'onDate') { + if ( + DateTime.fromISO(values.startDate) >= + DateTime.fromISO(options.endDate) + ) { + freqErrors.endDate = t`Please select an end date/time that comes after the start date/time.`; + } + + if ( + DateTime.fromISO(options.endDate) + .diff(DateTime.fromISO(values.startDate), 'days') + .toObject().days < NUM_DAYS_PER_FREQUENCY[freq] + ) { + const rule = new RRule( + buildRuleObj({ + startDate: values.startDate, + startTime: values.startTime, + frequency: freq, + ...options, + }) + ); + if (rule.all().length === 0) { + errors.startDate = t`Selected date range must have at least 1 schedule occurrence.`; + freqErrors.endDate = t`Selected date range must have at least 1 schedule occurrence.`; + } + } + } + if (Object.keys(freqErrors).length > 0) { + if (!errors.frequencyOptions) { + errors.frequencyOptions = {}; + } + errors.frequencyOptions[freq] = freqErrors; + } + }); + + return errors; + }; + return ( {() => ( { submitSchedule(values, launchConfig, surveyConfig, credentials); }} - validate={(values) => { - const errors = {}; - const { - end, - endDate, - frequency, - runOn, - runOnDayNumber, - startDate, - } = values; - - if ( - end === 'onDate' && - DateTime.fromISO(endDate) - .diff(DateTime.fromISO(startDate), 'days') - .toObject().days < NUM_DAYS_PER_FREQUENCY[frequency] - ) { - const rule = new RRule(buildRuleObj(values)); - if (rule.all().length === 0) { - errors.startDate = t`Selected date range must have at least 1 schedule occurrence.`; - errors.endDate = t`Selected date range must have at least 1 schedule occurrence.`; - } - } - - if ( - end === 'onDate' && - DateTime.fromISO(startDate) >= DateTime.fromISO(endDate) - ) { - errors.endDate = 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 = t`Please select a day number between 1 and 31.`; - } - return errors; - }} + validate={validate} > {(formik) => ( diff --git a/awx/ui/src/components/Schedule/shared/ScheduleForm.test.js b/awx/ui/src/components/Schedule/shared/ScheduleForm.test.js index b6889423b7..54d47c0de3 100644 --- a/awx/ui/src/components/Schedule/shared/ScheduleForm.test.js +++ b/awx/ui/src/components/Schedule/shared/ScheduleForm.test.js @@ -94,7 +94,7 @@ const defaultFieldsVisible = () => { expect( wrapper.find('FormGroup[label="Local time zone"]').find('HelpIcon').length ).toBe(1); - expect(wrapper.find('FormGroup[label="Run frequency"]').length).toBe(1); + expect(wrapper.find('FrequencySelect').length).toBe(1); }; const nonRRuleValuesMatch = () => { @@ -498,21 +498,19 @@ describe('', () => { expect(wrapper.find('DatePicker').prop('value')).toMatch(`${date}`); expect(wrapper.find('TimePicker').prop('time')).toMatch(`${time}`); expect(wrapper.find('select#schedule-timezone').prop('value')).toBe( - 'America/New_York' - ); - expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( - 'none' + 'UTC' ); + expect( + wrapper.find('FrequencySelect#schedule-frequency').prop('value') + ).toEqual([]); }); test('correct frequency details fields and values shown when frequency changed to minute', async () => { const runFrequencySelect = wrapper.find( - 'FormGroup[label="Run frequency"] FormSelect' + 'FrequencySelect#schedule-frequency' ); await act(async () => { - runFrequencySelect.invoke('onChange')('minute', { - target: { value: 'minute', key: 'minute', label: 'Minute' }, - }); + runFrequencySelect.invoke('onChange')(['minute']); }); wrapper.update(); defaultFieldsVisible(); @@ -523,20 +521,30 @@ describe('', () => { 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-every-frequencyOptions-minute') + .prop('value') + ).toBe(1); + expect( + wrapper.find('input#end-never-frequencyOptions-minute').prop('checked') + ).toBe(true); + expect( + wrapper.find('input#end-after-frequencyOptions-minute').prop('checked') + ).toBe(false); + expect( + wrapper + .find('input#end-on-date-frequencyOptions-minute') + .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' + 'FrequencySelect#schedule-frequency' ); await act(async () => { - runFrequencySelect.invoke('onChange')('hour', { - target: { value: 'hour', key: 'hour', label: 'Hour' }, - }); + runFrequencySelect.invoke('onChange')(['hour']); }); wrapper.update(); defaultFieldsVisible(); @@ -547,20 +555,28 @@ describe('', () => { 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-every-frequencyOptions-hour') + .prop('value') + ).toBe(1); + expect( + wrapper.find('input#end-never-frequencyOptions-hour').prop('checked') + ).toBe(true); + expect( + wrapper.find('input#end-after-frequencyOptions-hour').prop('checked') + ).toBe(false); + expect( + wrapper.find('input#end-on-date-frequencyOptions-hour').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' + 'FrequencySelect#schedule-frequency' ); await act(async () => { - runFrequencySelect.invoke('onChange')('day', { - target: { value: 'day', key: 'day', label: 'Day' }, - }); + runFrequencySelect.invoke('onChange')(['day']); }); wrapper.update(); defaultFieldsVisible(); @@ -571,20 +587,28 @@ describe('', () => { 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-every-frequencyOptions-day') + .prop('value') + ).toBe(1); + expect( + wrapper.find('input#end-never-frequencyOptions-day').prop('checked') + ).toBe(true); + expect( + wrapper.find('input#end-after-frequencyOptions-day').prop('checked') + ).toBe(false); + expect( + wrapper.find('input#end-on-date-frequencyOptions-day').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' + 'FrequencySelect#schedule-frequency' ); await act(async () => { - runFrequencySelect.invoke('onChange')('week', { - target: { value: 'week', key: 'week', label: 'Week' }, - }); + runFrequencySelect.invoke('onChange')(['week']); }); wrapper.update(); defaultFieldsVisible(); @@ -595,20 +619,28 @@ describe('', () => { 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-every-frequencyOptions-week') + .prop('value') + ).toBe(1); + expect( + wrapper.find('input#end-never-frequencyOptions-week').prop('checked') + ).toBe(true); + expect( + wrapper.find('input#end-after-frequencyOptions-week').prop('checked') + ).toBe(false); + expect( + wrapper.find('input#end-on-date-frequencyOptions-week').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' + 'FrequencySelect#schedule-frequency' ); await act(async () => { - runFrequencySelect.invoke('onChange')('month', { - target: { value: 'month', key: 'month', label: 'Month' }, - }); + runFrequencySelect.invoke('onChange')(['month']); }); wrapper.update(); defaultFieldsVisible(); @@ -619,31 +651,45 @@ describe('', () => { 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') + wrapper + .find('input#schedule-run-every-frequencyOptions-month') + .prop('value') ).toBe(1); - expect(wrapper.find('input#schedule-run-on-the').prop('checked')).toBe( - false - ); + expect( + wrapper.find('input#end-never-frequencyOptions-month').prop('checked') + ).toBe(true); + expect( + wrapper.find('input#end-after-frequencyOptions-month').prop('checked') + ).toBe(false); + expect( + wrapper.find('input#end-on-date-frequencyOptions-month').prop('checked') + ).toBe(false); + expect( + wrapper + .find('input#schedule-run-on-day-frequencyOptions-month') + .prop('checked') + ).toBe(true); + expect( + wrapper + .find('input#schedule-run-on-day-number-frequencyOptions-month') + .prop('value') + ).toBe(1); + expect( + wrapper + .find('input#schedule-run-on-the-frequencyOptions-month') + .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' + 'FrequencySelect#schedule-frequency' ); await act(async () => { - runFrequencySelect.invoke('onChange')('year', { - target: { value: 'year', key: 'year', label: 'Year' }, - }); + runFrequencySelect.invoke('onChange')(['year']); }); wrapper.update(); defaultFieldsVisible(); @@ -654,73 +700,125 @@ describe('', () => { 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') + wrapper + .find('input#schedule-run-every-frequencyOptions-year') + .prop('value') + ).toBe(1); + expect( + wrapper.find('input#end-never-frequencyOptions-year').prop('checked') + ).toBe(true); + expect( + wrapper.find('input#end-after-frequencyOptions-year').prop('checked') + ).toBe(false); + expect( + wrapper.find('input#end-on-date-frequencyOptions-year').prop('checked') + ).toBe(false); + expect( + wrapper + .find('input#schedule-run-on-day-frequencyOptions-year') + .prop('checked') + ).toBe(true); + expect( + wrapper + .find('input#schedule-run-on-day-number-frequencyOptions-year') + .prop('value') + ).toBe(1); + expect( + wrapper + .find('input#schedule-run-on-the-frequencyOptions-year') + .prop('checked') + ).toBe(false); + expect( + wrapper.find('select#schedule-run-on-day-month-frequencyOptions-year') + .length + ).toBe(1); + expect( + wrapper.find('select#schedule-run-on-the-month-frequencyOptions-year') + .length ).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('FrequencySelect#schedule-frequency').invoke('onChange')([ + 'minute', + ]); + }); + wrapper.update(); await act(async () => { wrapper - .find('FormGroup[label="Run frequency"] FormSelect') - .invoke('onChange')('minute', { - target: { value: 'minute', key: 'minute', label: 'Minute' }, + .find('Radio#end-after-frequencyOptions-minute') + .invoke('onChange')('after', { + target: { name: 'frequencyOptions.minute.end' }, }); }); wrapper.update(); - 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('input#end-never-frequencyOptions-minute').prop('checked') + ).toBe(false); + expect( + wrapper.find('input#end-after-frequencyOptions-minute').prop('checked') + ).toBe(true); + expect( + wrapper + .find('input#end-on-date-frequencyOptions-minute') + .prop('checked') + ).toBe(false); expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(1); - expect(wrapper.find('input#schedule-occurrences').prop('value')).toBe(1); + expect( + wrapper + .find('input#schedule-occurrences-frequencyOptions-minute') + .prop('value') + ).toBe(1); await act(async () => { - wrapper.find('Radio#end-never').invoke('onChange')('never', { - target: { name: 'end' }, + wrapper + .find('Radio#end-never-frequencyOptions-minute') + .invoke('onChange')('never', { + target: { name: 'frequencyOptions.minute.end' }, }); }); wrapper.update(); + expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); }); test('error shown when end date/time comes before start date/time', async () => { await act(async () => { + wrapper.find('FrequencySelect#schedule-frequency').invoke('onChange')([ + 'minute', + ]); + }); + wrapper.update(); + expect( + wrapper.find('input#end-never-frequencyOptions-minute').prop('checked') + ).toBe(true); + expect( + wrapper.find('input#end-after-frequencyOptions-minute').prop('checked') + ).toBe(false); + expect( wrapper - .find('FormGroup[label="Run frequency"] FormSelect') - .invoke('onChange')('minute', { - target: { value: 'minute', key: 'minute', label: 'Minute' }, - }); - }); - wrapper.update(); - 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); + .find('input#end-on-date-frequencyOptions-minute') + .prop('checked') + ).toBe(false); await act(async () => { - wrapper.find('Radio#end-on-date').invoke('onChange')('onDate', { - target: { name: 'end' }, + wrapper + .find('Radio#end-on-date-frequencyOptions-minute') + .invoke('onChange')('onDate', { + target: { name: 'frequencyOptions.minute.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); + expect( + wrapper.find('input#end-never-frequencyOptions-minute').prop('checked') + ).toBe(false); + expect( + wrapper.find('input#end-after-frequencyOptions-minute').prop('checked') + ).toBe(false); + expect( + wrapper + .find('input#end-on-date-frequencyOptions-minute') + .prop('checked') + ).toBe(true); await act(async () => { wrapper.find('DatePicker[aria-label="End date"]').prop('onChange')( '2020-03-14', @@ -739,26 +837,29 @@ describe('', () => { }); test('error shown when on day number is not between 1 and 31', async () => { - act(() => { - wrapper.find('select[id="schedule-frequency"]').invoke('onChange')( - { - currentTarget: { value: 'month', type: 'change' }, - target: { name: 'frequency', value: 'month' }, - }, - 'month' - ); + await act(async () => { + wrapper.find('FrequencySelect#schedule-frequency').invoke('onChange')([ + 'month', + ]); }); wrapper.update(); act(() => { - wrapper.find('input#schedule-run-on-day-number').simulate('change', { - target: { value: 32, name: 'runOnDayNumber' }, - }); + wrapper + .find('input#schedule-run-on-day-number-frequencyOptions-month') + .simulate('change', { + target: { + value: 32, + name: 'frequencyOptions.month.runOnDayNumber', + }, + }); }); wrapper.update(); expect( - wrapper.find('input#schedule-run-on-day-number').prop('value') + wrapper + .find('input#schedule-run-on-day-number-frequencyOptions-month') + .prop('value') ).toBe(32); await act(async () => { @@ -766,9 +867,9 @@ describe('', () => { }); wrapper.update(); - expect(wrapper.find('#schedule-run-on-helper').text()).toBe( - 'Please select a day number between 1 and 31.' - ); + expect( + wrapper.find('#schedule-run-on-frequencyOptions-month-helper').text() + ).toBe('Please select a day number between 1 and 31.'); }); }); @@ -928,9 +1029,9 @@ describe('', () => { expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); nonRRuleValuesMatch(); - expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( - 'none' - ); + expect( + wrapper.find('FrequencySelect#schedule-frequency').prop('value') + ).toEqual([]); }); test('initially renders expected fields and values with existing schedule that runs every 10 minutes', async () => { @@ -966,13 +1067,25 @@ describe('', () => { expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); nonRRuleValuesMatch(); - expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( - 'minute' - ); - expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(10); - expect(wrapper.find('input#end-never').prop('checked')).toBe(true); - expect(wrapper.find('input#end-after').prop('checked')).toBe(false); - expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); + expect( + wrapper.find('FrequencySelect#schedule-frequency').prop('value') + ).toEqual(['minute']); + expect( + wrapper + .find('input#schedule-run-every-frequencyOptions-minute') + .prop('value') + ).toBe(10); + expect( + wrapper.find('input#end-never-frequencyOptions-minute').prop('checked') + ).toBe(true); + expect( + wrapper.find('input#end-after-frequencyOptions-minute').prop('checked') + ).toBe(false); + expect( + wrapper + .find('input#end-on-date-frequencyOptions-minute') + .prop('checked') + ).toBe(false); }); test('initially renders expected fields and values with existing schedule that runs every hour 10 times', async () => { @@ -1009,14 +1122,28 @@ describe('', () => { expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); nonRRuleValuesMatch(); - expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( - 'hour' - ); - expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); - expect(wrapper.find('input#end-never').prop('checked')).toBe(false); - expect(wrapper.find('input#end-after').prop('checked')).toBe(true); - expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); - expect(wrapper.find('input#schedule-occurrences').prop('value')).toBe(10); + expect( + wrapper.find('FrequencySelect#schedule-frequency').prop('value') + ).toEqual(['hour']); + expect( + wrapper + .find('input#schedule-run-every-frequencyOptions-hour') + .prop('value') + ).toBe(1); + expect( + wrapper.find('input#end-never-frequencyOptions-hour').prop('checked') + ).toBe(false); + expect( + wrapper.find('input#end-after-frequencyOptions-hour').prop('checked') + ).toBe(true); + expect( + wrapper.find('input#end-on-date-frequencyOptions-hour').prop('checked') + ).toBe(false); + expect( + wrapper + .find('input#schedule-occurrences-frequencyOptions-hour') + .prop('value') + ).toBe(10); }); test('initially renders expected fields and values with existing schedule that runs every day', async () => { @@ -1053,13 +1180,23 @@ describe('', () => { expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); nonRRuleValuesMatch(); - expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( - 'day' - ); - expect(wrapper.find('input#end-never').prop('checked')).toBe(true); - expect(wrapper.find('input#end-after').prop('checked')).toBe(false); - expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); - expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); + expect( + wrapper.find('FrequencySelect#schedule-frequency').prop('value') + ).toEqual(['day']); + expect( + wrapper.find('input#end-never-frequencyOptions-day').prop('checked') + ).toBe(true); + expect( + wrapper.find('input#end-after-frequencyOptions-day').prop('checked') + ).toBe(false); + expect( + wrapper.find('input#end-on-date-frequencyOptions-day').prop('checked') + ).toBe(false); + expect( + wrapper + .find('input#schedule-run-every-frequencyOptions-day') + .prop('value') + ).toBe(1); }); test('initially renders expected fields and values with existing schedule that runs every week on m/w/f until Jan 1, 2020', async () => { @@ -1096,40 +1233,64 @@ describe('', () => { expect(wrapper.find('FormGroup[label="Run on"]').length).toBe(0); nonRRuleValuesMatch(); - expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( - 'week' - ); - expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); - expect(wrapper.find('input#end-never').prop('checked')).toBe(false); - expect(wrapper.find('input#end-after').prop('checked')).toBe(false); - expect(wrapper.find('input#end-on-date').prop('checked')).toBe(true); expect( - wrapper.find('input#schedule-days-of-week-sun').prop('checked') + wrapper.find('FrequencySelect#schedule-frequency').prop('value') + ).toEqual(['week']); + expect( + wrapper + .find('input#schedule-run-every-frequencyOptions-week') + .prop('value') + ).toBe(1); + expect( + wrapper.find('input#end-never-frequencyOptions-week').prop('checked') ).toBe(false); expect( - wrapper.find('input#schedule-days-of-week-mon').prop('checked') - ).toBe(true); - expect( - wrapper.find('input#schedule-days-of-week-tue').prop('checked') + wrapper.find('input#end-after-frequencyOptions-week').prop('checked') ).toBe(false); expect( - wrapper.find('input#schedule-days-of-week-wed').prop('checked') + wrapper.find('input#end-on-date-frequencyOptions-week').prop('checked') ).toBe(true); expect( - wrapper.find('input#schedule-days-of-week-thu').prop('checked') + wrapper + .find('input#schedule-days-of-week-sun-frequencyOptions-week') + .prop('checked') ).toBe(false); expect( - wrapper.find('input#schedule-days-of-week-fri').prop('checked') + wrapper + .find('input#schedule-days-of-week-mon-frequencyOptions-week') + .prop('checked') ).toBe(true); expect( - wrapper.find('input#schedule-days-of-week-sat').prop('checked') + wrapper + .find('input#schedule-days-of-week-tue-frequencyOptions-week') + .prop('checked') + ).toBe(false); + expect( + wrapper + .find('input#schedule-days-of-week-wed-frequencyOptions-week') + .prop('checked') + ).toBe(true); + expect( + wrapper + .find('input#schedule-days-of-week-thu-frequencyOptions-week') + .prop('checked') + ).toBe(false); + expect( + wrapper + .find('input#schedule-days-of-week-fri-frequencyOptions-week') + .prop('checked') + ).toBe(true); + expect( + wrapper + .find('input#schedule-days-of-week-sat-frequencyOptions-week') + .prop('checked') ).toBe(false); expect( wrapper.find('DatePicker[aria-label="End date"]').prop('value') ).toBe('2021-01-01'); expect( wrapper.find('TimePicker[aria-label="End time"]').prop('value') - ).toBe('1:00 AM'); + ).toBe('12:00 AM'); }); test('initially renders expected fields and values with existing schedule that runs every month on the last weekday', async () => { @@ -1169,25 +1330,43 @@ describe('', () => { expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); nonRRuleValuesMatch(); - expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( - 'month' - ); - expect(wrapper.find('input#end-never').prop('checked')).toBe(true); - expect(wrapper.find('input#end-after').prop('checked')).toBe(false); - expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); - expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); - expect(wrapper.find('input#schedule-run-on-day').prop('checked')).toBe( - false - ); - expect(wrapper.find('input#schedule-run-on-the').prop('checked')).toBe( - true - ); expect( - wrapper.find('select#schedule-run-on-the-occurrence').prop('value') + wrapper.find('FrequencySelect#schedule-frequency').prop('value') + ).toEqual(['month']); + expect( + wrapper.find('input#end-never-frequencyOptions-month').prop('checked') + ).toBe(true); + expect( + wrapper.find('input#end-after-frequencyOptions-month').prop('checked') + ).toBe(false); + expect( + wrapper.find('input#end-on-date-frequencyOptions-month').prop('checked') + ).toBe(false); + expect( + wrapper + .find('input#schedule-run-every-frequencyOptions-month') + .prop('value') + ).toBe(1); + expect( + wrapper + .find('input#schedule-run-on-day-frequencyOptions-month') + .prop('checked') + ).toBe(false); + expect( + wrapper + .find('input#schedule-run-on-the-frequencyOptions-month') + .prop('checked') + ).toBe(true); + expect( + wrapper + .find('select#schedule-run-on-the-occurrence-frequencyOptions-month') + .prop('value') ).toBe(-1); - expect(wrapper.find('select#schedule-run-on-the-day').prop('value')).toBe( - 'weekday' - ); + expect( + wrapper + .find('select#schedule-run-on-the-day-frequencyOptions-month') + .prop('value') + ).toBe('weekday'); }); test('initially renders expected fields and values with existing schedule that runs every year on the May 6', async () => { @@ -1224,24 +1403,42 @@ describe('', () => { expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); nonRRuleValuesMatch(); - expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( - 'year' - ); - expect(wrapper.find('input#end-never').prop('checked')).toBe(true); - expect(wrapper.find('input#end-after').prop('checked')).toBe(false); - expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); - expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); - expect(wrapper.find('input#schedule-run-on-day').prop('checked')).toBe( - true - ); - expect(wrapper.find('input#schedule-run-on-the').prop('checked')).toBe( - false - ); expect( - wrapper.find('select#schedule-run-on-day-month').prop('value') + wrapper.find('FrequencySelect#schedule-frequency').prop('value') + ).toEqual(['year']); + expect( + wrapper.find('input#end-never-frequencyOptions-year').prop('checked') + ).toBe(true); + expect( + wrapper.find('input#end-after-frequencyOptions-year').prop('checked') + ).toBe(false); + expect( + wrapper.find('input#end-on-date-frequencyOptions-year').prop('checked') + ).toBe(false); + expect( + wrapper + .find('input#schedule-run-every-frequencyOptions-year') + .prop('value') + ).toBe(1); + expect( + wrapper + .find('input#schedule-run-on-day-frequencyOptions-year') + .prop('checked') + ).toBe(true); + expect( + wrapper + .find('input#schedule-run-on-the-frequencyOptions-year') + .prop('checked') + ).toBe(false); + expect( + wrapper + .find('select#schedule-run-on-day-month-frequencyOptions-year') + .prop('value') ).toBe(5); expect( - wrapper.find('input#schedule-run-on-day-number').prop('value') + wrapper + .find('input#schedule-run-on-day-number-frequencyOptions-year') + .prop('value') ).toBe(6); }); }); diff --git a/awx/ui/src/components/Schedule/shared/ScheduleFormFields.js b/awx/ui/src/components/Schedule/shared/ScheduleFormFields.js new file mode 100644 index 0000000000..272ed187dc --- /dev/null +++ b/awx/ui/src/components/Schedule/shared/ScheduleFormFields.js @@ -0,0 +1,194 @@ +import React, { useState } from 'react'; +import { useField } from 'formik'; +import { FormGroup, Title } from '@patternfly/react-core'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import FormField from 'components/FormField'; +import { required } from 'util/validators'; +import { useConfig } from 'contexts/Config'; +import Popover from '../../Popover'; +import AnsibleSelect from '../../AnsibleSelect'; +import FrequencySelect, { SelectOption } from './FrequencySelect'; +import helpText from '../../../screens/Template/shared/JobTemplate.helptext'; +import { SubFormLayout, FormColumnLayout } from '../../FormLayout'; +import FrequencyDetailSubform from './FrequencyDetailSubform'; +import DateTimePicker from './DateTimePicker'; +import sortFrequencies from './sortFrequencies'; + +const SelectClearOption = styled(SelectOption)` + & > input[type='checkbox'] { + display: none; + } +`; + +export default function ScheduleFormFields({ + hasDaysToKeepField, + zoneOptions, + zoneLinks, +}) { + const [timezone, timezoneMeta] = useField({ + name: 'timezone', + validate: required(t`Select a value for this field`), + }); + const [frequency, frequencyMeta, frequencyHelper] = useField({ + name: 'frequency', + validate: required(t`Select a value for this field`), + }); + const [timezoneMessage, setTimezoneMessage] = useState(''); + const warnLinkedTZ = (event, selectedValue) => { + if (zoneLinks[selectedValue]) { + setTimezoneMessage( + t`Warning: ${selectedValue} is a link to ${zoneLinks[selectedValue]} and will be saved as that.` + ); + } else { + setTimezoneMessage(''); + } + timezone.onChange(event, selectedValue); + }; + let timezoneValidatedStatus = 'default'; + if (timezoneMeta.touched && timezoneMeta.error) { + timezoneValidatedStatus = 'error'; + } else if (timezoneMessage) { + timezoneValidatedStatus = 'warning'; + } + const config = useConfig(); + + // const [exceptionFrequency, exceptionFrequencyMeta, exceptionFrequencyHelper] = + // useField({ + // name: 'exceptionFrequency', + // validate: required(t`Select a value for this field`), + // }); + + const updateFrequency = (setFrequency) => (values) => { + setFrequency(values.sort(sortFrequencies)); + }; + + return ( + <> + + + + } + > + + + + + {t`None (run once)`} + {t`Minute`} + {t`Hour`} + {t`Day`} + {t`Week`} + {t`Month`} + {t`Year`} + + + {hasDaysToKeepField ? ( + + ) : null} + {frequency.value.length ? ( + + + {t`Frequency Details`} + + {frequency.value.map((val) => ( + + + + ))} + {/* {t`Exceptions`} + + + {t`None`} + {t`Minute`} + {t`Hour`} + {t`Day`} + {t`Week`} + {t`Month`} + {t`Year`} + + + {exceptionFrequency.value.map((val) => ( + + + + ))} */} + + ) : null} + > + ); +} diff --git a/awx/ui/src/components/Schedule/shared/SchedulePromptableFields.js b/awx/ui/src/components/Schedule/shared/SchedulePromptableFields.js index c5d2abb880..406398806b 100644 --- a/awx/ui/src/components/Schedule/shared/SchedulePromptableFields.js +++ b/awx/ui/src/components/Schedule/shared/SchedulePromptableFields.js @@ -41,20 +41,9 @@ function SchedulePromptableFields({ resetForm({ values: { ...initialValues, - daysOfWeek: values.daysOfWeek, description: values.description, - end: values.end, - endDateTime: values.endDateTime, frequency: values.frequency, - interval: values.interval, name: values.name, - occurences: values.occurances, - runOn: values.runOn, - runOnDayMonth: values.runOnDayMonth, - runOnDayNumber: values.runOnDayNumber, - runOnTheDay: values.runOnTheDay, - runOnTheMonth: values.runOnTheMonth, - runOnTheOccurence: values.runOnTheOccurance, startDateTime: values.startDateTime, timezone: values.timezone, }, diff --git a/awx/ui/src/components/Schedule/shared/UnsupportedScheduleForm.js b/awx/ui/src/components/Schedule/shared/UnsupportedScheduleForm.js new file mode 100644 index 0000000000..195101d73f --- /dev/null +++ b/awx/ui/src/components/Schedule/shared/UnsupportedScheduleForm.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import { Button, Form, ActionGroup, Alert } from '@patternfly/react-core'; + +export default function UnsupportedScheduleForm({ schedule, handleCancel }) { + return ( + + + {t`Schedule Rules`}: + + {schedule.rrule.split(' ').join('\n')} + + + + {t`Cancel`} + + + + ); +} diff --git a/awx/ui/src/components/Schedule/shared/buildRuleObj.js b/awx/ui/src/components/Schedule/shared/buildRuleObj.js index ae375c672f..3fcba27240 100644 --- a/awx/ui/src/components/Schedule/shared/buildRuleObj.js +++ b/awx/ui/src/components/Schedule/shared/buildRuleObj.js @@ -3,30 +3,42 @@ import { RRule } from 'rrule'; import { DateTime } from 'luxon'; import { getRRuleDayConstants } from 'util/dates'; +window.RRule = RRule; +window.DateTime = DateTime; + const parseTime = (time) => [ DateTime.fromFormat(time, 'h:mm a').hour, DateTime.fromFormat(time, 'h:mm a').minute, ]; -export default function buildRuleObj(values) { +export function buildDtStartObj(values) { // Dates are formatted like "YYYY-MM-DD" const [startYear, startMonth, startDay] = values.startDate.split('-'); // Times are formatted like "HH:MM:SS" or "HH:MM" if no seconds // have been specified const [startHour, startMinute] = parseTime(values.startTime); + const dateString = `${startYear}${pad(startMonth)}${pad(startDay)}T${pad( + startHour + )}${pad(startMinute)}00`; + const rruleString = values.timezone + ? `DTSTART;TZID=${values.timezone}:${dateString}` + : `DTSTART:${dateString}Z`; + const rule = RRule.fromString(rruleString); + + return rule; +} + +function pad(num) { + if (typeof num === 'string') { + return num; + } + return num < 10 ? `0${num}` : num; +} + +export default function buildRuleObj(values) { const ruleObj = { interval: values.interval, - dtstart: new Date( - Date.UTC( - startYear, - parseInt(startMonth, 10) - 1, - startDay, - startHour, - startMinute - ) - ), - tzid: values.timezone, }; switch (values.frequency) { @@ -79,22 +91,20 @@ export default function buildRuleObj(values) { ruleObj.count = values.occurrences; break; case 'onDate': { - const [endYear, endMonth, endDay] = values.endDate.split('-'); - const [endHour, endMinute] = parseTime(values.endTime); - ruleObj.until = new Date( - Date.UTC( - endYear, - parseInt(endMonth, 10) - 1, - endDay, - endHour, - endMinute - ) - ); + const localEndDate = DateTime.fromISO(`${values.endDate}T000000`, { + zone: values.timezone, + }); + const localEndTime = localEndDate.set({ + hour: endHour, + minute: endMinute, + second: 0, + }); + ruleObj.until = localEndTime.toJSDate(); break; } default: - throw new Error(t`End did not match an expected value`); + throw new Error(t`End did not match an expected value (${values.end})`); } } diff --git a/awx/ui/src/components/Schedule/shared/buildRuleSet.js b/awx/ui/src/components/Schedule/shared/buildRuleSet.js new file mode 100644 index 0000000000..c15594e0c6 --- /dev/null +++ b/awx/ui/src/components/Schedule/shared/buildRuleSet.js @@ -0,0 +1,45 @@ +import { RRule, RRuleSet } from 'rrule'; +import buildRuleObj, { buildDtStartObj } from './buildRuleObj'; + +window.RRuleSet = RRuleSet; + +const frequencies = ['minute', 'hour', 'day', 'week', 'month', 'year']; +export default function buildRuleSet(values) { + const set = new RRuleSet(); + + const startRule = buildDtStartObj({ + startDate: values.startDate, + startTime: values.startTime, + timezone: values.timezone, + }); + set.rrule(startRule); + + if (values.frequency.length === 0) { + const rule = buildRuleObj({ + startDate: values.startDate, + startTime: values.startTime, + timezone: values.timezone, + frequency: 'none', + interval: 1, + }); + set.rrule(new RRule(rule)); + } + + frequencies.forEach((frequency) => { + if (!values.frequency.includes(frequency)) { + return; + } + const rule = buildRuleObj({ + startDate: values.startDate, + startTime: values.startTime, + timezone: values.timezone, + frequency, + ...values.frequencyOptions[frequency], + }); + set.rrule(new RRule(rule)); + }); + + // TODO: exclusions + + return set; +} diff --git a/awx/ui/src/components/Schedule/shared/buildRuleSet.test.js b/awx/ui/src/components/Schedule/shared/buildRuleSet.test.js new file mode 100644 index 0000000000..3d7831507c --- /dev/null +++ b/awx/ui/src/components/Schedule/shared/buildRuleSet.test.js @@ -0,0 +1,246 @@ +import { RRule } from 'rrule'; +import buildRuleSet from './buildRuleSet'; + +import { DateTime } from 'luxon'; + +describe('buildRuleSet', () => { + test('should build minutely recurring rrule', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['minute'], + frequencyOptions: { + minute: { + interval: 1, + end: 'never', + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual( + 'DTSTART:20220613T123000Z\nRRULE:INTERVAL=1;FREQ=MINUTELY' + ); + }); + + test('should build hourly recurring rrule', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['hour'], + frequencyOptions: { + hour: { + interval: 1, + end: 'never', + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual( + 'DTSTART:20220613T123000Z\nRRULE:INTERVAL=1;FREQ=HOURLY' + ); + }); + + test('should build daily recurring rrule', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['day'], + frequencyOptions: { + day: { + interval: 1, + end: 'never', + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual( + 'DTSTART:20220613T123000Z\nRRULE:INTERVAL=1;FREQ=DAILY' + ); + }); + + test('should build weekly recurring rrule', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['week'], + frequencyOptions: { + week: { + interval: 1, + end: 'never', + daysOfWeek: [RRule.SU], + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual( + 'DTSTART:20220613T123000Z\nRRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=SU' + ); + }); + + test('should build monthly by day recurring rrule', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['month'], + frequencyOptions: { + month: { + interval: 1, + end: 'never', + runOn: 'day', + runOnDayNumber: 15, + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual( + 'DTSTART:20220613T123000Z\nRRULE:INTERVAL=1;FREQ=MONTHLY;BYMONTHDAY=15' + ); + }); + + test('should build monthly by weekday recurring rrule', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['month'], + frequencyOptions: { + month: { + interval: 1, + end: 'never', + runOn: 'the', + runOnTheOccurrence: 2, + runOnTheDay: 'monday', + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual( + 'DTSTART:20220613T123000Z\nRRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=2;BYDAY=MO' + ); + }); + + test('should build yearly by day recurring rrule', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['year'], + frequencyOptions: { + year: { + interval: 1, + end: 'never', + runOn: 'day', + runOnDayMonth: 3, + runOnDayNumber: 15, + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual( + 'DTSTART:20220613T123000Z\nRRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=15' + ); + }); + + test('should build yearly by weekday recurring rrule', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['year'], + frequencyOptions: { + year: { + interval: 1, + end: 'never', + runOn: 'the', + runOnTheOccurrence: 4, + runOnTheDay: 'monday', + runOnTheMonth: 6, + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual( + 'DTSTART:20220613T123000Z\nRRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=4;BYDAY=MO;BYMONTH=6' + ); + }); + + test('should build combined frequencies', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['minute', 'month'], + frequencyOptions: { + minute: { + interval: 1, + end: 'never', + }, + month: { + interval: 1, + end: 'never', + runOn: 'the', + runOnTheOccurrence: 2, + runOnTheDay: 'monday', + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual(`DTSTART:20220613T123000Z +RRULE:INTERVAL=1;FREQ=MINUTELY +RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=2;BYDAY=MO`); + }); + + test('should build combined frequencies with end dates', () => { + const values = { + startDate: '2022-06-01', + startTime: '12:30 PM', + timezone: 'US/Eastern', + frequency: ['hour', 'month'], + frequencyOptions: { + hour: { + interval: 2, + end: 'onDate', + endDate: '2026-07-02', + endTime: '1:00 PM', + occurrences: 1, + }, + month: { + interval: 1, + end: 'onDate', + runOn: 'the', + runOnTheOccurrence: 2, + runOnTheDay: 'monday', + runOnDayNumber: 1, + endDate: '2026-06-02', + endTime: '1:00 PM', + occurrences: 1, + }, + }, + exceptionFrequency: [], + exceptionOptions: {}, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual(`DTSTART;TZID=US/Eastern:20220601T123000 +RRULE:INTERVAL=2;FREQ=HOURLY;UNTIL=20260702T170000Z +RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=2;BYDAY=MO;UNTIL=20260602T170000Z`); + }); + + test('should build single occurence', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: [], + frequencyOptions: {}, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual(`DTSTART:20220613T123000Z +RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY`); + }); +}); diff --git a/awx/ui/src/components/Schedule/shared/parseRuleObj.js b/awx/ui/src/components/Schedule/shared/parseRuleObj.js new file mode 100644 index 0000000000..9699a6a51a --- /dev/null +++ b/awx/ui/src/components/Schedule/shared/parseRuleObj.js @@ -0,0 +1,235 @@ +import { RRule, rrulestr } from 'rrule'; +import { dateToInputDateTime } from 'util/dates'; +import { DateTime } from 'luxon'; +import sortFrequencies from './sortFrequencies'; + +export class UnsupportedRRuleError extends Error { + constructor(message) { + super(message); + this.name = 'UnsupportedRRuleError'; + } +} + +export default function parseRuleObj(schedule) { + let values = { + frequency: [], + frequencyOptions: {}, + exceptionFrequency: [], + exceptionOptions: {}, + timezone: schedule.timezone, + }; + const ruleset = rrulestr(schedule.rrule.replace(' ', '\n'), { + forceset: true, + }); + + const ruleStrings = ruleset.valueOf(); + ruleStrings.forEach((ruleString) => { + const type = ruleString.match(/^[A-Z]+/)[0]; + switch (type) { + case 'DTSTART': + values = parseDtstart(schedule, values); + break; + case 'RRULE': + values = parseRrule(ruleString, schedule, values); + break; + default: + throw new UnsupportedRRuleError(`Unsupported rrule type: ${type}`); + } + }); + + if (isSingleOccurrence(values)) { + values.frequency = []; + values.frequencyOptions = {}; + } + + return values; +} + +function isSingleOccurrence(values) { + if (values.frequency.length > 1) { + return false; + } + if (values.frequency[0] !== 'minute') { + return false; + } + const options = values.frequencyOptions.minute; + return options.end === 'after' && options.occurrences === 1; +} + +function parseDtstart(schedule, values) { + // TODO: should this rely on DTSTART in rruleset rather than schedule.dtstart? + const [startDate, startTime] = dateToInputDateTime( + schedule.dtstart, + schedule.timezone + ); + return { + ...values, + startDate, + startTime, + }; +} + +const frequencyTypes = { + [RRule.MINUTELY]: 'minute', + [RRule.HOURLY]: 'hour', + [RRule.DAILY]: 'day', + [RRule.WEEKLY]: 'week', + [RRule.MONTHLY]: 'month', + [RRule.YEARLY]: 'year', +}; + +function parseRrule(rruleString, schedule, values) { + const { + origOptions: { + bymonth, + bymonthday, + bysetpos, + byweekday, + count, + freq, + interval, + until, + }, + } = RRule.fromString(rruleString); + + const now = DateTime.now(); + const closestQuarterHour = DateTime.fromMillis( + Math.ceil(now.ts / 900000) * 900000 + ); + const tomorrow = closestQuarterHour.plus({ days: 1 }); + const [, time] = dateToInputDateTime(closestQuarterHour.toISO()); + const [tomorrowDate] = dateToInputDateTime(tomorrow.toISO()); + + const options = { + endDate: tomorrowDate, + endTime: time, + occurrences: 1, + interval: 1, + end: 'never', + }; + + if (until) { + options.end = 'onDate'; + const end = DateTime.fromISO(until.toISOString()); + const [endDate, endTime] = dateToInputDateTime(end, schedule.timezone); + options.endDate = endDate; + options.endTime = endTime; + } else if (count) { + options.end = 'after'; + options.occurrences = count; + } + + if (interval) { + options.interval = interval; + } + + if (typeof freq !== 'number') { + throw new Error(`Unexpected rrule frequency: ${freq}`); + } + const frequency = frequencyTypes[freq]; + if (values.frequency.includes(frequency)) { + throw new Error(`Duplicate frequency types not supported (${frequency})`); + } + + if (freq === RRule.WEEKLY && byweekday) { + options.daysOfWeek = byweekday; + } + + if (freq === RRule.MONTHLY) { + options.runOn = 'day'; + options.runOnTheOccurrence = 1; + options.runOnTheDay = 'sunday'; + options.runOnDayNumber = 1; + + if (bymonthday) { + options.runOnDayNumber = bymonthday; + } + if (bysetpos) { + options.runOn = 'the'; + options.runOnTheOccurrence = bysetpos; + options.runOnTheDay = generateRunOnTheDay(byweekday); + } + } + + if (freq === RRule.YEARLY) { + options.runOn = 'day'; + options.runOnTheOccurrence = 1; + options.runOnTheDay = 'sunday'; + options.runOnTheMonth = 1; + options.runOnDayMonth = 1; + options.runOnDayNumber = 1; + + if (bymonthday) { + options.runOnDayNumber = bymonthday; + options.runOnDayMonth = bymonth; + } + if (bysetpos) { + options.runOn = 'the'; + options.runOnTheOccurrence = bysetpos; + options.runOnTheDay = generateRunOnTheDay(byweekday); + options.runOnTheMonth = bymonth; + } + } + + if (values.frequencyOptions.frequency) { + throw new UnsupportedRRuleError('Duplicate frequency types not supported'); + } + + return { + ...values, + frequency: [...values.frequency, frequency].sort(sortFrequencies), + frequencyOptions: { + ...values.frequencyOptions, + [frequency]: options, + }, + }; +} + +function generateRunOnTheDay(days = []) { + if ( + [ + RRule.MO, + RRule.TU, + RRule.WE, + RRule.TH, + RRule.FR, + RRule.SA, + RRule.SU, + ].every((element) => days.indexOf(element) > -1) + ) { + return 'day'; + } + if ( + [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR].every( + (element) => days.indexOf(element) > -1 + ) + ) { + return 'weekday'; + } + if ([RRule.SA, RRule.SU].every((element) => days.indexOf(element) > -1)) { + return 'weekendDay'; + } + if (days.indexOf(RRule.MO) > -1) { + return 'monday'; + } + if (days.indexOf(RRule.TU) > -1) { + return 'tuesday'; + } + if (days.indexOf(RRule.WE) > -1) { + return 'wednesday'; + } + if (days.indexOf(RRule.TH) > -1) { + return 'thursday'; + } + if (days.indexOf(RRule.FR) > -1) { + return 'friday'; + } + if (days.indexOf(RRule.SA) > -1) { + return 'saturday'; + } + if (days.indexOf(RRule.SU) > -1) { + return 'sunday'; + } + + return null; +} diff --git a/awx/ui/src/components/Schedule/shared/parseRuleObj.test.js b/awx/ui/src/components/Schedule/shared/parseRuleObj.test.js new file mode 100644 index 0000000000..9f0fcba1b6 --- /dev/null +++ b/awx/ui/src/components/Schedule/shared/parseRuleObj.test.js @@ -0,0 +1,244 @@ +import { DateTime, Settings } from 'luxon'; +import { RRule } from 'rrule'; +import parseRuleObj from './parseRuleObj'; +import buildRuleSet from './buildRuleSet'; + +describe(parseRuleObj, () => { + let origNow = Settings.now; + beforeEach(() => { + const expectedNow = DateTime.local(2022, 6, 1, 13, 0, 0); + Settings.now = () => expectedNow.toMillis(); + }); + + afterEach(() => { + Settings.now = origNow; + }); + + test('should parse weekly recurring rrule', () => { + const schedule = { + rrule: + 'DTSTART;TZID=US/Eastern:20220608T123000 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=MO', + dtstart: '2022-06-13T16:30:00Z', + timezone: 'US/Eastern', + until: '', + dtend: null, + }; + + const parsed = parseRuleObj(schedule); + + expect(parsed).toEqual({ + startDate: '2022-06-13', + startTime: '12:30 PM', + timezone: 'US/Eastern', + frequency: ['week'], + frequencyOptions: { + week: { + interval: 1, + end: 'never', + occurrences: 1, + endDate: '2022-06-02', + endTime: '1:00 PM', + daysOfWeek: [RRule.MO], + }, + }, + exceptionFrequency: [], + exceptionOptions: {}, + }); + }); + + test('should parse weekly recurring rrule with end date', () => { + const schedule = { + rrule: + 'DTSTART;TZID=America/New_York:20200402T144500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20210101T050000Z', + dtstart: '2020-04-02T18:45:00Z', + timezone: 'America/New_York', + }; + + const parsed = parseRuleObj(schedule); + + expect(parsed).toEqual({ + startDate: '2020-04-02', + startTime: '2:45 PM', + timezone: 'America/New_York', + frequency: ['week'], + frequencyOptions: { + week: { + interval: 1, + end: 'onDate', + occurrences: 1, + endDate: '2021-01-01', + endTime: '12:00 AM', + daysOfWeek: [RRule.MO, RRule.WE, RRule.FR], + }, + }, + exceptionFrequency: [], + exceptionOptions: {}, + }); + }); + + test('should parse hourly rule with end date', () => { + const schedule = { + rrule: + 'DTSTART;TZID=US/Eastern:20220608T123000 RRULE:INTERVAL=1;FREQ=HOURLY;UNTIL=20230608T170000Z', + dtstart: '2022-06-08T16:30:00Z', + timezone: 'US/Eastern', + }; + + const parsed = parseRuleObj(schedule); + + expect(parsed).toEqual({ + startDate: '2022-06-08', + startTime: '12:30 PM', + timezone: 'US/Eastern', + frequency: ['hour'], + frequencyOptions: { + hour: { + interval: 1, + end: 'onDate', + occurrences: 1, + endDate: '2023-06-08', + endTime: '1:00 PM', + }, + }, + exceptionFrequency: [], + exceptionOptions: {}, + }); + }); + + // TODO: do we need to support this? It's technically invalid RRULE, but the + // API has historically supported it as a special case (but cast to UTC?) + test.skip('should parse hourly rule with end date in local time', () => { + const schedule = { + rrule: + 'DTSTART;TZID=US/Eastern:20220608T123000 RRULE:INTERVAL=1;FREQ=HOURLY;UNTIL=20230608T130000', + dtstart: '2022-06-08T16:30:00', + timezone: 'US/Eastern', + }; + + const parsed = parseRuleObj(schedule); + + expect(parsed).toEqual({ + startDate: '2022-06-08', + startTime: '12:30 PM', + timezone: 'US/Eastern', + frequency: ['hour'], + frequencyOptions: { + hour: { + interval: 1, + end: 'onDate', + occurrences: 1, + endDate: '2023-06-08', + endTime: '1:00 PM', + }, + }, + exceptionFrequency: [], + exceptionOptions: {}, + }); + }); + + test('should parse non-recurring rrule', () => { + const schedule = { + rrule: + 'DTSTART;TZID=America/New_York:20220610T130000 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY', + dtstart: '2022-06-10T17:00:00Z', + dtend: '2022-06-10T17:00:00Z', + timezone: 'US/Eastern', + until: '', + }; + + expect(parseRuleObj(schedule)).toEqual({ + startDate: '2022-06-10', + startTime: '1:00 PM', + timezone: 'US/Eastern', + frequency: [], + frequencyOptions: {}, + exceptionFrequency: [], + exceptionOptions: {}, + }); + }); + + // buildRuleSet is well-tested; use it to verify this does the inverse + test('should re-parse built complex schedule', () => { + const values = { + startDate: '2022-06-01', + startTime: '12:30 PM', + timezone: 'US/Eastern', + frequency: ['minute', 'month'], + frequencyOptions: { + minute: { + interval: 1, + end: 'never', + endDate: '2022-06-02', + endTime: '1:00 PM', + occurrences: 1, + }, + month: { + interval: 1, + end: 'never', + runOn: 'the', + runOnTheOccurrence: 2, + runOnTheDay: 'monday', + runOnDayNumber: 1, + endDate: '2022-06-02', + endTime: '1:00 PM', + occurrences: 1, + }, + }, + exceptionFrequency: [], + exceptionOptions: {}, + }; + + const ruleSet = buildRuleSet(values); + const parsed = parseRuleObj({ + rrule: ruleSet.toString(), + dtstart: '2022-06-01T12:30:00', + dtend: '2022-06-01T12:30:00', + timezone: 'US/Eastern', + }); + + expect(parsed).toEqual(values); + }); + + test('should parse built complex schedule with end dates', () => { + const rulesetString = `DTSTART;TZID=US/Eastern:20220601T123000 +RRULE:INTERVAL=2;FREQ=HOURLY;UNTIL=20260702T170000Z +RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=2;BYDAY=MO;UNTIL=20260602T170000Z`; + const values = { + startDate: '2022-06-01', + startTime: '12:30 PM', + timezone: 'US/Eastern', + frequency: ['hour', 'month'], + frequencyOptions: { + hour: { + interval: 2, + end: 'onDate', + endDate: '2026-07-02', + endTime: '1:00 PM', + occurrences: 1, + }, + month: { + interval: 1, + end: 'onDate', + runOn: 'the', + runOnTheOccurrence: 2, + runOnTheDay: 'monday', + runOnDayNumber: 1, + endDate: '2026-06-02', + endTime: '1:00 PM', + occurrences: 1, + }, + }, + exceptionFrequency: [], + exceptionOptions: {}, + }; + + const parsed = parseRuleObj({ + rrule: rulesetString, + dtstart: '2022-06-01T16:30:00Z', + dtend: '2026-06-07T16:30:00Z', + timezone: 'US/Eastern', + }); + + expect(parsed).toEqual(values); + }); +}); diff --git a/awx/ui/src/components/Schedule/shared/sortFrequencies.js b/awx/ui/src/components/Schedule/shared/sortFrequencies.js new file mode 100644 index 0000000000..4212a6bc12 --- /dev/null +++ b/awx/ui/src/components/Schedule/shared/sortFrequencies.js @@ -0,0 +1,18 @@ +const ORDER = { + minute: 1, + hour: 2, + day: 3, + week: 4, + month: 5, + year: 6, +}; + +export default function sortFrequencies(a, b) { + if (ORDER[a] < ORDER[b]) { + return -1; + } + if (ORDER[a] > ORDER[b]) { + return 1; + } + return 0; +} diff --git a/awx/ui/src/util/validators.js b/awx/ui/src/util/validators.js index 51a68126ac..e4a3671008 100644 --- a/awx/ui/src/util/validators.js +++ b/awx/ui/src/util/validators.js @@ -186,3 +186,20 @@ export function regExp() { return undefined; }; } + +export function requiredPositiveInteger() { + return (value) => { + if (typeof value === 'number') { + if (!Number.isInteger(value)) { + return t`This field must be an integer`; + } + if (value < 1) { + return t`This field must be greater than 0`; + } + } + if (!value) { + return t`Select a value for this field`; + } + return undefined; + }; +}
+ {t`Frequency Details`} +
+ {getPeriodLabel()} +
- {schedule.rrule} -
+ {schedule.rrule.split(' ').join('\n')} +