diff --git a/awx/ui/src/components/Schedule/shared/ScheduleFormFields.js b/awx/ui/src/components/Schedule/shared/ScheduleFormFields.js index 272ed187dc..5b61eb54a6 100644 --- a/awx/ui/src/components/Schedule/shared/ScheduleFormFields.js +++ b/awx/ui/src/components/Schedule/shared/ScheduleFormFields.js @@ -3,6 +3,7 @@ import { useField } from 'formik'; import { FormGroup, Title } from '@patternfly/react-core'; import { t } from '@lingui/macro'; import styled from 'styled-components'; +import 'styled-components/macro'; import FormField from 'components/FormField'; import { required } from 'util/validators'; import { useConfig } from 'contexts/Config'; @@ -53,11 +54,11 @@ export default function ScheduleFormFields({ } const config = useConfig(); - // const [exceptionFrequency, exceptionFrequencyMeta, exceptionFrequencyHelper] = - // useField({ - // name: 'exceptionFrequency', - // validate: required(t`Select a value for this field`), - // }); + const [exceptionFrequency, exceptionFrequencyMeta, exceptionFrequencyHelper] = + useField({ + name: 'exceptionFrequency', + validate: required(t`Select a value for this field`), + }); const updateFrequency = (setFrequency) => (values) => { setFrequency(values.sort(sortFrequencies)); @@ -151,34 +152,40 @@ export default function ScheduleFormFields({ /> ))} - {/* {t`Exceptions`} - - {t`Exceptions`} + + - {t`None`} - {t`Minute`} - {t`Hour`} - {t`Day`} - {t`Week`} - {t`Month`} - {t`Year`} - - + + {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/buildRuleSet.js b/awx/ui/src/components/Schedule/shared/buildRuleSet.js index c15594e0c6..0f8182bc01 100644 --- a/awx/ui/src/components/Schedule/shared/buildRuleSet.js +++ b/awx/ui/src/components/Schedule/shared/buildRuleSet.js @@ -39,7 +39,19 @@ export default function buildRuleSet(values) { set.rrule(new RRule(rule)); }); - // TODO: exclusions + frequencies.forEach((frequency) => { + if (!values.exceptionFrequency?.includes(frequency)) { + return; + } + const rule = buildRuleObj({ + startDate: values.startDate, + startTime: values.startTime, + timezone: values.timezone, + frequency, + ...values.exceptionOptions[frequency], + }); + set.exrule(new RRule(rule)); + }); return set; } diff --git a/awx/ui/src/components/Schedule/shared/buildRuleSet.test.js b/awx/ui/src/components/Schedule/shared/buildRuleSet.test.js index 3d7831507c..42fde67981 100644 --- a/awx/ui/src/components/Schedule/shared/buildRuleSet.test.js +++ b/awx/ui/src/components/Schedule/shared/buildRuleSet.test.js @@ -243,4 +243,257 @@ RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=2;BYDAY=MO;UNTIL=20260602T170000Z`); expect(ruleSet.toString()).toEqual(`DTSTART:20220613T123000Z RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY`); }); + + test('should build minutely exception', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['minute'], + frequencyOptions: { + minute: { + interval: 1, + end: 'never', + }, + }, + exceptionFrequency: ['minute'], + exceptionOptions: { + minute: { + interval: 3, + end: 'never', + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual( + [ + 'DTSTART:20220613T123000Z', + 'RRULE:INTERVAL=1;FREQ=MINUTELY', + 'EXRULE:INTERVAL=3;FREQ=MINUTELY', + ].join('\n') + ); + }); + + test('should build hourly exception', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['minute'], + frequencyOptions: { + minute: { + interval: 1, + end: 'never', + }, + }, + exceptionFrequency: ['hour'], + exceptionOptions: { + hour: { + interval: 3, + end: 'never', + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual( + [ + 'DTSTART:20220613T123000Z', + 'RRULE:INTERVAL=1;FREQ=MINUTELY', + 'EXRULE:INTERVAL=3;FREQ=HOURLY', + ].join('\n') + ); + }); + + test('should build daily exception', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['minute'], + frequencyOptions: { + minute: { + interval: 1, + end: 'never', + }, + }, + exceptionFrequency: ['day'], + exceptionOptions: { + day: { + interval: 3, + end: 'never', + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual( + [ + 'DTSTART:20220613T123000Z', + 'RRULE:INTERVAL=1;FREQ=MINUTELY', + 'EXRULE:INTERVAL=3;FREQ=DAILY', + ].join('\n') + ); + }); + + test('should build weekly exception', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['minute'], + frequencyOptions: { + minute: { + interval: 1, + end: 'never', + }, + }, + exceptionFrequency: ['week'], + exceptionOptions: { + week: { + interval: 3, + end: 'never', + daysOfWeek: [RRule.SU], + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual( + [ + 'DTSTART:20220613T123000Z', + 'RRULE:INTERVAL=1;FREQ=MINUTELY', + 'EXRULE:INTERVAL=3;FREQ=WEEKLY;BYDAY=SU', + ].join('\n') + ); + }); + + test('should build monthly exception by day', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['minute'], + frequencyOptions: { + minute: { + interval: 1, + end: 'never', + }, + }, + exceptionFrequency: ['month'], + exceptionOptions: { + month: { + interval: 3, + end: 'never', + runOn: 'day', + runOnDayNumber: 15, + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual( + [ + 'DTSTART:20220613T123000Z', + 'RRULE:INTERVAL=1;FREQ=MINUTELY', + 'EXRULE:INTERVAL=3;FREQ=MONTHLY;BYMONTHDAY=15', + ].join('\n') + ); + }); + + test('should build monthly exception by weekday', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['minute'], + frequencyOptions: { + minute: { + interval: 1, + end: 'never', + }, + }, + exceptionFrequency: ['month'], + exceptionOptions: { + month: { + interval: 3, + end: 'never', + runOn: 'the', + runOnTheOccurrence: 2, + runOnTheDay: 'monday', + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual( + [ + 'DTSTART:20220613T123000Z', + 'RRULE:INTERVAL=1;FREQ=MINUTELY', + 'EXRULE:INTERVAL=3;FREQ=MONTHLY;BYSETPOS=2;BYDAY=MO', + ].join('\n') + ); + }); + + test('should build annual exception by day', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['minute'], + frequencyOptions: { + minute: { + interval: 1, + end: 'never', + }, + }, + exceptionFrequency: ['year'], + exceptionOptions: { + year: { + interval: 1, + end: 'never', + runOn: 'day', + runOnDayMonth: 3, + runOnDayNumber: 15, + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual( + [ + 'DTSTART:20220613T123000Z', + 'RRULE:INTERVAL=1;FREQ=MINUTELY', + 'EXRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=15', + ].join('\n') + ); + }); + + test('should build annual exception by weekday', () => { + const values = { + startDate: '2022-06-13', + startTime: '12:30 PM', + frequency: ['minute'], + frequencyOptions: { + minute: { + interval: 1, + end: 'never', + }, + }, + exceptionFrequency: ['year'], + exceptionOptions: { + year: { + interval: 1, + end: 'never', + runOn: 'the', + runOnTheOccurrence: 4, + runOnTheDay: 'monday', + runOnTheMonth: 6, + }, + }, + }; + + const ruleSet = buildRuleSet(values); + expect(ruleSet.toString()).toEqual( + [ + 'DTSTART:20220613T123000Z', + 'RRULE:INTERVAL=1;FREQ=MINUTELY', + 'EXRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=4;BYDAY=MO;BYMONTH=6', + ].join('\n') + ); + }); }); diff --git a/awx/ui/src/components/Schedule/shared/parseRuleObj.js b/awx/ui/src/components/Schedule/shared/parseRuleObj.js index 9699a6a51a..a676231193 100644 --- a/awx/ui/src/components/Schedule/shared/parseRuleObj.js +++ b/awx/ui/src/components/Schedule/shared/parseRuleObj.js @@ -32,6 +32,9 @@ export default function parseRuleObj(schedule) { case 'RRULE': values = parseRrule(ruleString, schedule, values); break; + case 'EXRULE': + values = parseExRule(ruleString, schedule, values); + break; default: throw new UnsupportedRRuleError(`Unsupported rrule type: ${type}`); } @@ -79,6 +82,54 @@ const frequencyTypes = { }; function parseRrule(rruleString, schedule, values) { + const { frequency, options } = parseRule( + rruleString, + schedule, + values.exceptionFrequency + ); + + if (values.frequencyOptions[frequency]) { + throw new UnsupportedRRuleError( + 'Duplicate exception frequency types not supported' + ); + } + + return { + ...values, + frequency: [...values.frequency, frequency].sort(sortFrequencies), + frequencyOptions: { + ...values.frequencyOptions, + [frequency]: options, + }, + }; +} + +function parseExRule(exruleString, schedule, values) { + const { frequency, options } = parseRule( + exruleString, + schedule, + values.exceptionFrequency + ); + + if (values.exceptionOptions[frequency]) { + throw new UnsupportedRRuleError( + 'Duplicate exception frequency types not supported' + ); + } + + return { + ...values, + exceptionFrequency: [...values.exceptionFrequency, frequency].sort( + sortFrequencies + ), + exceptionOptions: { + ...values.exceptionOptions, + [frequency]: options, + }, + }; +} + +function parseRule(ruleString, schedule, frequencies) { const { origOptions: { bymonth, @@ -90,7 +141,7 @@ function parseRrule(rruleString, schedule, values) { interval, until, }, - } = RRule.fromString(rruleString); + } = RRule.fromString(ruleString); const now = DateTime.now(); const closestQuarterHour = DateTime.fromMillis( @@ -127,7 +178,7 @@ function parseRrule(rruleString, schedule, values) { throw new Error(`Unexpected rrule frequency: ${freq}`); } const frequency = frequencyTypes[freq]; - if (values.frequency.includes(frequency)) { + if (frequencies.includes(frequency)) { throw new Error(`Duplicate frequency types not supported (${frequency})`); } @@ -171,17 +222,9 @@ function parseRrule(rruleString, schedule, values) { } } - 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, - }, + frequency, + options, }; } diff --git a/awx/ui/src/components/Schedule/shared/parseRuleObj.test.js b/awx/ui/src/components/Schedule/shared/parseRuleObj.test.js index 9f0fcba1b6..426998bcc2 100644 --- a/awx/ui/src/components/Schedule/shared/parseRuleObj.test.js +++ b/awx/ui/src/components/Schedule/shared/parseRuleObj.test.js @@ -241,4 +241,51 @@ RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=2;BYDAY=MO;UNTIL=20260602T170000Z`; expect(parsed).toEqual(values); }); + + test('should parse exemptions', () => { + const schedule = { + rrule: [ + 'DTSTART;TZID=US/Eastern:20220608T123000', + 'RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=MO', + 'EXRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=1;BYDAY=MO', + ].join(' '), + 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: ['month'], + exceptionOptions: { + month: { + interval: 1, + end: 'never', + endDate: '2022-06-02', + endTime: '1:00 PM', + occurrences: 1, + runOn: 'the', + runOnDayNumber: 1, + runOnTheOccurrence: 1, + runOnTheDay: 'monday', + }, + }, + }); + }); });