diff --git a/awx/ui/src/components/DetailList/DetailList.js b/awx/ui/src/components/DetailList/DetailList.js index 78bc160f64..dceaa1cba9 100644 --- a/awx/ui/src/components/DetailList/DetailList.js +++ b/awx/ui/src/components/DetailList/DetailList.js @@ -29,4 +29,8 @@ export default styled(DetailList)` --column-count: 3; } `} + + & + & { + margin-top: 20px; + } `; diff --git a/awx/ui/src/components/Schedule/ScheduleDetail/FrequencyDetails.js b/awx/ui/src/components/Schedule/ScheduleDetail/FrequencyDetails.js index 48f55601ed..4bde844499 100644 --- a/awx/ui/src/components/Schedule/ScheduleDetail/FrequencyDetails.js +++ b/awx/ui/src/components/Schedule/ScheduleDetail/FrequencyDetails.js @@ -10,7 +10,13 @@ const Label = styled.div` font-weight: var(--pf-global--FontWeight--bold); `; -export default function FrequencyDetails({ type, label, options, timezone }) { +export default function FrequencyDetails({ + type, + label, + options, + timezone, + isException, +}) { const getRunEveryLabel = () => { const { interval } = options; switch (type) { @@ -77,11 +83,17 @@ export default function FrequencyDetails({ type, label, options, timezone }) { 6: t`Sunday`, }; + const prefix = isException ? `exception-${type}` : `frequency-${type}`; + return (
- + {type === 'week' ? ( weekdays[d.weekday]) .join(', ')} + dataCy={`${prefix}-days-of-week`} /> ) : null} - - + +
); @@ -104,11 +121,15 @@ function sortWeekday(a, b) { return a.weekday - b.weekday; } -function RunOnDetail({ type, options }) { +function RunOnDetail({ type, options, prefix }) { if (type === 'month') { if (options.runOn === 'day') { return ( - + ); } const dayOfWeek = options.runOnTheDay; @@ -129,6 +150,7 @@ function RunOnDetail({ type, options }) { /> ) } + dataCy={`${prefix}-run-on-day`} /> ); } @@ -152,6 +174,7 @@ function RunOnDetail({ type, options }) { ); } @@ -186,6 +209,7 @@ function RunOnDetail({ type, options }) { /> ) } + dataCy={`${prefix}-run-on-day`} /> ); } diff --git a/awx/ui/src/components/Schedule/ScheduleDetail/ScheduleDetail.js b/awx/ui/src/components/Schedule/ScheduleDetail/ScheduleDetail.js index 9d5611b902..b7516ea213 100644 --- a/awx/ui/src/components/Schedule/ScheduleDetail/ScheduleDetail.js +++ b/awx/ui/src/components/Schedule/ScheduleDetail/ScheduleDetail.js @@ -2,7 +2,6 @@ import 'styled-components/macro'; import React, { useCallback, useEffect } from 'react'; import { Link, useHistory, useLocation } from 'react-router-dom'; import styled from 'styled-components'; - import { t } from '@lingui/macro'; import { Chip, Divider, Title, Button } from '@patternfly/react-core'; import { Schedule } from 'types'; @@ -60,6 +59,10 @@ const FrequencyDetailsContainer = styled.div` padding-bottom: var(--pf-global--spacer--md); border-bottom: 1px solid var(--pf-global--palette--black-300); } + + & + & { + margin-top: calc(var(--pf-global--spacer--lg) * -1); + } `; function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) { @@ -161,10 +164,14 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) { month: t`Month`, year: t`Year`, }; - const { frequency, frequencyOptions } = parseRuleObj(schedule); + const { frequency, frequencyOptions, exceptionFrequency, exceptionOptions } = + parseRuleObj(schedule); const repeatFrequency = frequency.length ? frequency.map((f) => frequencies[f]).join(', ') : t`None (Run Once)`; + const exceptionRepeatFrequency = exceptionFrequency.length + ? exceptionFrequency.map((f) => frequencies[f]).join(', ') + : t`None (Run Once)`; const { ask_credential_on_launch, @@ -271,43 +278,84 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) { isDisabled={isDisabled} /> - - + + + + - {frequency.length ? ( -

- {t`Frequency Details`} -

- {frequency.map((freq) => ( - - ))} +
+

+ {t`Frequency Details`} +

+ {frequency.map((freq) => ( + + ))} +
+
+ ) : null} + {exceptionFrequency.length ? ( + +
+

+ {t`Frequency Exception Details`} +

+ {exceptionFrequency.map((freq) => ( + + ))} +
) : null} {hasDaysToKeepField ? ( - + ) : null} {ask_job_type_on_launch && ( - + )} {showInventoryDetail && ( )} {ask_verbosity_on_launch && ( - + )} {ask_scm_branch_on_launch && ( - + + )} + {ask_limit_on_launch && ( + )} - {ask_limit_on_launch && } {showDiffModeDetail && ( )} {showCredentialsDetail && ( @@ -382,6 +446,7 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) { ))} } + dataCy="schedule-credentials" /> )} {showTagsDetail && ( @@ -405,6 +470,7 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) { ))} } + dataCy="schedule-job-tags" /> )} {showSkipTagsDetail && ( @@ -428,6 +494,7 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) { ))} } + dataCy="schedule-skip-tags" /> )} {showVariablesDetail && ( diff --git a/awx/ui/src/components/Schedule/shared/FrequencyDetailSubform.js b/awx/ui/src/components/Schedule/shared/FrequencyDetailSubform.js index 69b54ab154..615d814b8d 100644 --- a/awx/ui/src/components/Schedule/shared/FrequencyDetailSubform.js +++ b/awx/ui/src/components/Schedule/shared/FrequencyDetailSubform.js @@ -45,7 +45,7 @@ const Checkbox = styled(_Checkbox)` } `; -const FrequencyDetailSubform = ({ frequency, prefix }) => { +const FrequencyDetailSubform = ({ frequency, prefix, isException }) => { const id = prefix.replace('.', '-'); const [runOnDayMonth] = useField({ name: `${prefix}.runOnDayMonth`, @@ -220,7 +220,7 @@ const FrequencyDetailSubform = ({ frequency, prefix }) => { validated={ !intervalMeta.touched || !intervalMeta.error ? 'default' : 'error' } - label={t`Run every`} + label={isException ? t`Skip every` : t`Run every`} >
0 && !scheduleHasInstances(values)) { + errors.exceptionFrequency = t`This schedule has no occurrences due to the selected exceptions.`; + } + return errors; }; @@ -518,3 +523,24 @@ ScheduleForm.defaultProps = { }; export default ScheduleForm; + +function scheduleHasInstances(values) { + let rangeToCheck = 1; + values.frequency.forEach((freq) => { + if (NUM_DAYS_PER_FREQUENCY[freq] > rangeToCheck) { + rangeToCheck = NUM_DAYS_PER_FREQUENCY[freq]; + } + }); + + const ruleSet = buildRuleSet(values, true); + const startDate = DateTime.fromISO(values.startDate); + const endDate = startDate.plus({ days: rangeToCheck }); + const instances = ruleSet.between( + startDate.toJSDate(), + endDate.toJSDate(), + true, + (date, i) => i === 0 + ); + + return instances.length > 0; +} diff --git a/awx/ui/src/components/Schedule/shared/ScheduleForm.test.js b/awx/ui/src/components/Schedule/shared/ScheduleForm.test.js index 54d47c0de3..47936fc314 100644 --- a/awx/ui/src/components/Schedule/shared/ScheduleForm.test.js +++ b/awx/ui/src/components/Schedule/shared/ScheduleForm.test.js @@ -86,7 +86,7 @@ const mockSchedule = { let wrapper; -const defaultFieldsVisible = () => { +const defaultFieldsVisible = (isExceptionsVisible) => { expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Start date/time"]').length).toBe(1); @@ -94,7 +94,11 @@ const defaultFieldsVisible = () => { expect( wrapper.find('FormGroup[label="Local time zone"]').find('HelpIcon').length ).toBe(1); - expect(wrapper.find('FrequencySelect').length).toBe(1); + if (isExceptionsVisible) { + expect(wrapper.find('FrequencySelect').length).toBe(2); + } else { + expect(wrapper.find('FrequencySelect').length).toBe(1); + } }; const nonRRuleValuesMatch = () => { @@ -513,7 +517,7 @@ describe('', () => { runFrequencySelect.invoke('onChange')(['minute']); }); wrapper.update(); - defaultFieldsVisible(); + defaultFieldsVisible(true); expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0); @@ -547,7 +551,7 @@ describe('', () => { runFrequencySelect.invoke('onChange')(['hour']); }); wrapper.update(); - defaultFieldsVisible(); + defaultFieldsVisible(true); expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0); @@ -579,7 +583,7 @@ describe('', () => { runFrequencySelect.invoke('onChange')(['day']); }); wrapper.update(); - defaultFieldsVisible(); + defaultFieldsVisible(true); expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0); @@ -611,7 +615,7 @@ describe('', () => { runFrequencySelect.invoke('onChange')(['week']); }); wrapper.update(); - defaultFieldsVisible(); + defaultFieldsVisible(true); expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); expect(wrapper.find('FormGroup[label="On days"]').length).toBe(1); @@ -643,7 +647,7 @@ describe('', () => { runFrequencySelect.invoke('onChange')(['month']); }); wrapper.update(); - defaultFieldsVisible(); + defaultFieldsVisible(true); expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0); @@ -692,7 +696,7 @@ describe('', () => { runFrequencySelect.invoke('onChange')(['year']); }); wrapper.update(); - defaultFieldsVisible(); + defaultFieldsVisible(true); expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0); @@ -1058,7 +1062,7 @@ describe('', () => { wrapper.update(); expect(wrapper.find('ScheduleForm').length).toBe(1); - defaultFieldsVisible(); + defaultFieldsVisible(true); expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); @@ -1113,7 +1117,7 @@ describe('', () => { wrapper.update(); expect(wrapper.find('ScheduleForm').length).toBe(1); - defaultFieldsVisible(); + defaultFieldsVisible(true); expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(1); @@ -1171,7 +1175,7 @@ describe('', () => { wrapper.update(); expect(wrapper.find('ScheduleForm').length).toBe(1); - defaultFieldsVisible(); + defaultFieldsVisible(true); expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0); @@ -1224,7 +1228,7 @@ describe('', () => { wrapper.update(); expect(wrapper.find('ScheduleForm').length).toBe(1); - defaultFieldsVisible(); + defaultFieldsVisible(true); expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(1); @@ -1318,10 +1322,7 @@ describe('', () => { wrapper.update(); expect(wrapper.find('ScheduleForm').length).toBe(1); - defaultFieldsVisible(); - - expect(wrapper.find('ScheduleForm').length).toBe(1); - defaultFieldsVisible(); + defaultFieldsVisible(true); expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Run on"]').length).toBe(1); @@ -1394,7 +1395,7 @@ describe('', () => { wrapper.update(); expect(wrapper.find('ScheduleForm').length).toBe(1); - defaultFieldsVisible(); + defaultFieldsVisible(true); expect(wrapper.find('FormGroup[label="End"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Run on"]').length).toBe(1); diff --git a/awx/ui/src/components/Schedule/shared/ScheduleFormFields.js b/awx/ui/src/components/Schedule/shared/ScheduleFormFields.js index 9ed37caa46..adf784d302 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'; @@ -54,11 +55,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)); @@ -152,42 +153,53 @@ 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/buildRuleObj.js b/awx/ui/src/components/Schedule/shared/buildRuleObj.js index 3fcba27240..c631ed959d 100644 --- a/awx/ui/src/components/Schedule/shared/buildRuleObj.js +++ b/awx/ui/src/components/Schedule/shared/buildRuleObj.js @@ -36,11 +36,19 @@ function pad(num) { return num < 10 ? `0${num}` : num; } -export default function buildRuleObj(values) { +export default function buildRuleObj(values, includeStart) { const ruleObj = { interval: values.interval, }; + if (includeStart) { + ruleObj.dtstart = buildDateTime( + values.startDate, + values.startTime, + values.timezone + ); + } + switch (values.frequency) { case 'none': ruleObj.count = 1; @@ -91,16 +99,11 @@ export default function buildRuleObj(values) { ruleObj.count = values.occurrences; break; case 'onDate': { - const [endHour, endMinute] = parseTime(values.endTime); - const localEndDate = DateTime.fromISO(`${values.endDate}T000000`, { - zone: values.timezone, - }); - const localEndTime = localEndDate.set({ - hour: endHour, - minute: endMinute, - second: 0, - }); - ruleObj.until = localEndTime.toJSDate(); + ruleObj.until = buildDateTime( + values.endDate, + values.endTime, + values.timezone + ); break; } default: @@ -110,3 +113,16 @@ export default function buildRuleObj(values) { return ruleObj; } + +function buildDateTime(dateString, timeString, timezone) { + const localDate = DateTime.fromISO(`${dateString}T000000`, { + zone: timezone, + }); + const [hour, minute] = parseTime(timeString); + const localTime = localDate.set({ + hour, + minute, + second: 0, + }); + return localTime.toJSDate(); +} diff --git a/awx/ui/src/components/Schedule/shared/buildRuleSet.js b/awx/ui/src/components/Schedule/shared/buildRuleSet.js index c15594e0c6..070a0ef663 100644 --- a/awx/ui/src/components/Schedule/shared/buildRuleSet.js +++ b/awx/ui/src/components/Schedule/shared/buildRuleSet.js @@ -4,24 +4,29 @@ import buildRuleObj, { buildDtStartObj } from './buildRuleObj'; window.RRuleSet = RRuleSet; const frequencies = ['minute', 'hour', 'day', 'week', 'month', 'year']; -export default function buildRuleSet(values) { +export default function buildRuleSet(values, useUTCStart) { 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({ + if (!useUTCStart) { + const startRule = buildDtStartObj({ startDate: values.startDate, startTime: values.startTime, timezone: values.timezone, - frequency: 'none', - interval: 1, }); + set.rrule(startRule); + } + + if (values.frequency.length === 0) { + const rule = buildRuleObj( + { + startDate: values.startDate, + startTime: values.startTime, + timezone: values.timezone, + frequency: 'none', + interval: 1, + }, + useUTCStart + ); set.rrule(new RRule(rule)); } @@ -29,17 +34,35 @@ export default function buildRuleSet(values) { if (!values.frequency.includes(frequency)) { return; } - const rule = buildRuleObj({ - startDate: values.startDate, - startTime: values.startTime, - timezone: values.timezone, - frequency, - ...values.frequencyOptions[frequency], - }); + const rule = buildRuleObj( + { + startDate: values.startDate, + startTime: values.startTime, + timezone: values.timezone, + frequency, + ...values.frequencyOptions[frequency], + }, + useUTCStart + ); 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], + }, + useUTCStart + ); + 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', + }, + }, + }); + }); });