Complex schedules UI (#12445)

* refactor ScheduleFormFields into own file

* refactor ScheduleForm

* wip complex schedules form

* build rruleset from inputs

* update schedule form validation for multiple repeat frequencies

* add basic rrule set parsing when opening schedule form

* complex schedule bugfixes, handle edge cases, etc

* fix schedule saving/parsing for single-occurrence schedules

* working with timezone issues

* fix rrule until times to be in UTC

* update tests for new schedule form format

* update ouiaIds

* tweak schedules spacing

* update ScheduleForm tests

* show message for unsupported schedule types

* default schedules to browser timezone

* show error type/message in ErrorDetail

* shows frequencies on ScheduleDetails view

* handles nullish values
This commit is contained in:
Keith Grant 2022-08-11 13:55:52 -07:00 committed by GitHub
parent 993dd61024
commit cae2c06190
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 2273 additions and 920 deletions

View File

@ -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}{' '}
<strong>{response?.status}</strong>
</CardBody>
<CardBody>
<CardBody css="max-width: 70vw">
{Array.isArray(message) ? (
<ul>
{message.map((m) =>
@ -70,9 +71,16 @@ function ErrorDetail({ error }) {
};
const renderStack = () => (
<CardBody css="white-space: pre; font-family: var(--pf-global--FontFamily--monospace)">
{error.stack}
</CardBody>
<>
<CardBody>
<strong>
{error.name}: {error.message}
</strong>
</CardBody>
<CardBody css="white-space: pre; font-family: var(--pf-global--FontFamily--monospace)">
{error.stack}
</CardBody>
</>
);
return (

View File

@ -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`

View File

@ -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 },

View File

@ -80,9 +80,7 @@ describe('<ScheduleAdd />', () => {
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('<ScheduleAdd />', () => {
});
});
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('<ScheduleAdd />', () => {
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('<ScheduleAdd />', () => {
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('<ScheduleAdd />', () => {
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('<ScheduleAdd />', () => {
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('<ScheduleAdd />', () => {
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('<ScheduleAdd />', () => {
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('<ScheduleAdd />', () => {
'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('<ScheduleAdd />', () => {
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('<ScheduleAdd />', () => {
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('<ScheduleAdd />', () => {
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('<ScheduleAdd />', () => {
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('<ScheduleAdd />', () => {
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',

View File

@ -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 (
<Plural
value={interval}
one="{interval} minute"
other="{interval} minutes"
/>
);
case 'hour':
return (
<Plural
value={interval}
one="{interval} hour"
other="{interval} hours"
/>
);
case 'day':
return (
<Plural
value={interval}
one="{interval} day"
other="{interval} days"
/>
);
case 'week':
return (
<Plural
value={interval}
one="{interval} week"
other="{interval} weeks"
/>
);
case 'month':
return (
<Plural
value={interval}
one="{interval} month"
other="{interval} months"
/>
);
case 'year':
return (
<Plural
value={interval}
one="{interval} year"
other="{interval} years"
/>
);
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 (
<div>
<Label>{label}</Label>
<DetailList gutter="sm">
<Detail label={t`Run every`} value={getRunEveryLabel()} />
{type === 'week' ? (
<Detail
label={t`On days`}
value={options.daysOfWeek
.sort(sortWeekday)
.map((d) => weekdays[d.weekday])
.join(', ')}
/>
) : null}
<RunOnDetail type={type} options={options} />
<Detail label={t`End`} value={getEndValue(type, options, timezone)} />
</DetailList>
</div>
);
}
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 (
<Detail label={t`Run on`} value={t`Day ${options.runOnDayNumber}`} />
);
}
const dayOfWeek = options.runOnTheDay;
return (
<Detail
label={t`Run on`}
value={
options.runOnDayNumber === -1 ? (
t`The last ${dayOfWeek}`
) : (
<SelectOrdinal
value={options.runOnDayNumber}
one={`The first ${dayOfWeek}`}
two={`The second ${dayOfWeek}`}
_3={`The third ${dayOfWeek}`}
_4={`The fourth ${dayOfWeek}`}
_5={`The fifth ${dayOfWeek}`}
/>
)
}
/>
);
}
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 (
<Detail
label={t`Run on`}
value={`${months[options.runOnTheMonth]} ${options.runOnDayMonth}`}
/>
);
}
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 (
<Detail
label={t`Run on`}
value={
options.runOnTheOccurrence === -1 ? (
t`The last ${weekday} of ${month}`
) : (
<SelectOrdinal
value={options.runOnTheOccurrence}
one={`The first ${weekday} of ${month}`}
two={`The second ${weekday} of ${month}`}
_3={`The third ${weekday} of ${month}`}
_4={`The fourth ${weekday} of ${month}`}
_5={`The fifth ${weekday} of ${month}`}
/>
)
}
/>
);
}
return null;
}
function getEndValue(type, options, timezone) {
if (options.end === 'never') {
return t`Never`;
}
if (options.end === 'after') {
const numOccurrences = options.occurrences;
return (
<Plural
value={numOccurrences}
one="After {numOccurrences} occurrence"
other="After {numOccurrences} occurrences"
/>
);
}
const date = DateTime.fromFormat(
`${options.endDate} ${options.endTime}`,
'yyyy-MM-dd h:mm a',
{
zone: timezone,
}
);
return formatDateString(date, timezone);
}

View File

@ -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)}
/>
<Detail label={t`Repeat Frequency`} value={repeatFrequency} />
</DetailList>
{frequency.length ? (
<FrequencyDetailsContainer>
<p>
<strong>{t`Frequency Details`}</strong>
</p>
{frequency.map((freq) => (
<FrequencyDetails
key={freq}
type={freq}
label={frequencies[freq]}
options={frequencyOptions[freq]}
timezone={timezone}
/>
))}
</FrequencyDetailsContainer>
) : null}
<DetailList gutter="sm">
{hasDaysToKeepField ? (
<Detail label={t`Days of Data to Keep`} value={daysToKeep} />
) : null}

View File

@ -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) {

View File

@ -195,9 +195,7 @@ describe('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
'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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
});
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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
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('<ScheduleEdit />', () => {
startDateTime: undefined,
description: '',
extra_data: {},
occurrences: 1,
runOnTheOccurrence: 1,
name: 'foo',
inventory: 702,
rrule:
@ -723,9 +734,8 @@ describe('<ScheduleEdit />', () => {
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',

View File

@ -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()]),
});

View File

@ -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 <Plural value={intervalValue} one="minute" other="minutes" />;
case 'hour':
@ -202,12 +207,14 @@ const FrequencyDetailSubform = () => {
}
};
/* eslint-disable no-restricted-globals */
return (
<>
<p css="grid-column: 1/-1">
<b>{getPeriodLabel()}</b>
</p>
<FormGroup
name="interval"
fieldId="schedule-run-every"
name={`${prefix}.interval`}
fieldId={`schedule-run-every-${id}`}
helperTextInvalid={intervalMeta.error}
isRequired
validated={
@ -218,7 +225,7 @@ const FrequencyDetailSubform = () => {
<div css="display: flex">
<TextInput
css="margin-right: 10px;"
id="schedule-run-every"
id={`schedule-run-every-${id}`}
type="number"
min="1"
step="1"
@ -230,10 +237,10 @@ const FrequencyDetailSubform = () => {
<RunEveryLabel>{getRunEveryLabel()}</RunEveryLabel>
</div>
</FormGroup>
{frequency?.value === 'week' && (
{frequency === 'week' && (
<FormGroup
name="daysOfWeek"
fieldId="schedule-days-of-week"
name={`${prefix}.daysOfWeek`}
fieldId={`schedule-days-of-week-${id}`}
helperTextInvalid={daysOfWeekMeta.error}
isRequired
validated={
@ -246,89 +253,89 @@ const FrequencyDetailSubform = () => {
<div css="display: flex">
<Checkbox
label={t`Sun`}
isChecked={daysOfWeek.value.includes(RRule.SU)}
isChecked={daysOfWeek.value?.includes(RRule.SU)}
onChange={(checked) => {
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`}
/>
<Checkbox
label={t`Mon`}
isChecked={daysOfWeek.value.includes(RRule.MO)}
isChecked={daysOfWeek.value?.includes(RRule.MO)}
onChange={(checked) => {
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`}
/>
<Checkbox
label={t`Tue`}
isChecked={daysOfWeek.value.includes(RRule.TU)}
isChecked={daysOfWeek.value?.includes(RRule.TU)}
onChange={(checked) => {
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`}
/>
<Checkbox
label={t`Wed`}
isChecked={daysOfWeek.value.includes(RRule.WE)}
isChecked={daysOfWeek.value?.includes(RRule.WE)}
onChange={(checked) => {
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`}
/>
<Checkbox
label={t`Thu`}
isChecked={daysOfWeek.value.includes(RRule.TH)}
isChecked={daysOfWeek.value?.includes(RRule.TH)}
onChange={(checked) => {
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`}
/>
<Checkbox
label={t`Fri`}
isChecked={daysOfWeek.value.includes(RRule.FR)}
isChecked={daysOfWeek.value?.includes(RRule.FR)}
onChange={(checked) => {
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`}
/>
<Checkbox
label={t`Sat`}
isChecked={daysOfWeek.value.includes(RRule.SA)}
isChecked={daysOfWeek.value?.includes(RRule.SA)}
onChange={(checked) => {
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`}
/>
</div>
</FormGroup>
)}
{(frequency?.value === 'month' || frequency?.value === 'year') &&
!isNaN(new Date(startDate.value)) && (
{(frequency === 'month' || frequency === 'year') &&
!Number.isNaN(new Date(startDate.value)) && (
<FormGroup
name="runOn"
fieldId="schedule-run-on"
name={`${prefix}.runOn`}
fieldId={`schedule-run-on-${id}`}
helperTextInvalid={runOnMeta.error}
isRequired
validated={
@ -337,11 +344,11 @@ const FrequencyDetailSubform = () => {
label={t`Run on`}
>
<RunOnRadio
id="schedule-run-on-day"
name="runOn"
id={`schedule-run-on-day-${id}`}
name={`${prefix}.runOn`}
label={
<div css="display: flex;align-items: center;">
{frequency?.value === 'month' && (
{frequency === 'month' && (
<span
id="radio-schedule-run-on-day"
css="margin-right: 10px;"
@ -349,9 +356,9 @@ const FrequencyDetailSubform = () => {
<Trans>Day</Trans>
</span>
)}
{frequency?.value === 'year' && (
{frequency === 'year' && (
<AnsibleSelect
id="schedule-run-on-day-month"
id={`schedule-run-on-day-month-${id}`}
css="margin-right: 10px"
isDisabled={runOn.value !== 'day'}
data={monthOptions}
@ -359,7 +366,7 @@ const FrequencyDetailSubform = () => {
/>
)}
<TextInput
id="schedule-run-on-day-number"
id={`schedule-run-on-day-number-${id}`}
type="number"
min="1"
max="31"
@ -380,18 +387,18 @@ const FrequencyDetailSubform = () => {
}}
/>
<RunOnRadio
id="schedule-run-on-the"
name="runOn"
id={`schedule-run-on-the-${id}`}
name={`${prefix}.runOn`}
label={
<div css="display: flex;align-items: center;">
<span
id="radio-schedule-run-on-the"
id={`radio-schedule-run-on-the-${id}`}
css="margin-right: 10px;"
>
<Trans>The</Trans>
</span>
<AnsibleSelect
id="schedule-run-on-the-occurrence"
id={`schedule-run-on-the-occurrence-${id}`}
isDisabled={runOn.value !== 'the'}
data={[
{ value: 1, key: 'first', label: t`First` },
@ -412,7 +419,7 @@ const FrequencyDetailSubform = () => {
{...runOnTheOccurrence}
/>
<AnsibleSelect
id="schedule-run-on-the-day"
id={`schedule-run-on-the-day-${id}`}
isDisabled={runOn.value !== 'the'}
data={[
{
@ -464,16 +471,16 @@ const FrequencyDetailSubform = () => {
]}
{...runOnTheDay}
/>
{frequency?.value === 'year' && (
{frequency === 'year' && (
<>
<span
id="of-schedule-run-on-the-month"
id={`of-schedule-run-on-the-month-${id}`}
css="margin-left: 10px;"
>
<Trans>of</Trans>
</span>
<AnsibleSelect
id="schedule-run-on-the-month"
id={`schedule-run-on-the-month-${id}`}
isDisabled={runOn.value !== 'the'}
data={monthOptions}
{...runOnTheMonth}
@ -492,16 +499,16 @@ const FrequencyDetailSubform = () => {
</FormGroup>
)}
<FormGroup
name="end"
fieldId="schedule-end"
name={`${prefix}.end`}
fieldId={`schedule-end-${id}`}
helperTextInvalid={endMeta.error}
isRequired
validated={!endMeta.touched || !endMeta.error ? 'default' : 'error'}
label={t`End`}
>
<Radio
id="end-never"
name="end"
id={`end-never-${id}`}
name={`${prefix}.end`}
label={t`Never`}
value="never"
isChecked={end.value === 'never'}
@ -509,11 +516,11 @@ const FrequencyDetailSubform = () => {
event.target.value = 'never';
end.onChange(event);
}}
ouiaId="end-never-radio-button"
ouiaId={`end-never-radio-button-${id}`}
/>
<Radio
id="end-after"
name="end"
id={`end-after-${id}`}
name={`${prefix}.end`}
label={t`After number of occurrences`}
value="after"
isChecked={end.value === 'after'}
@ -521,11 +528,11 @@ const FrequencyDetailSubform = () => {
event.target.value = 'after';
end.onChange(event);
}}
ouiaId="end-after-radio-button"
ouiaId={`end-after-radio-button-${id}`}
/>
<Radio
id="end-on-date"
name="end"
id={`end-on-date-${id}`}
name={`${prefix}.end`}
label={t`On date`}
value="onDate"
isChecked={end.value === 'onDate'}
@ -533,25 +540,24 @@ const FrequencyDetailSubform = () => {
event.target.value = 'onDate';
end.onChange(event);
}}
ouiaId="end-on-radio-button"
ouiaId={`end-on-radio-button-${id}`}
/>
</FormGroup>
{end?.value === 'after' && (
<FormField
id="schedule-occurrences"
id={`schedule-occurrences-${id}`}
label={t`Occurrences`}
name="occurrences"
name={`${prefix}.occurrences`}
type="number"
min="1"
step="1"
validate={required(null)}
isRequired
/>
)}
{end?.value === 'onDate' && (
<DateTimePicker
dateFieldName={dateFieldName}
timeFieldName={timeFieldName}
dateFieldName={`${prefix}.endDate`}
timeFieldName={`${prefix}.endTime`}
label={t`End date/time`}
/>
)}

View File

@ -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 (
<Select
variant={SelectVariant.checkbox}
onSelect={onSelect}
selections={value}
placeholderText={placeholderText}
onToggle={onToggle}
isOpen={isOpen}
ouiaId={`frequency-select-${id}`}
>
{children}
</Select>
);
}
FrequencySelect.propTypes = {
value: arrayOf(string).isRequired,
};
export { SelectOption, SelectVariant };

View File

@ -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 (
<>
<FormField
id="schedule-name"
label={t`Name`}
name="name"
type="text"
validate={required(null)}
isRequired
/>
<FormField
id="schedule-description"
label={t`Description`}
name="description"
type="text"
/>
<DateTimePicker
dateFieldName={dateFieldName}
timeFieldName={timeFieldName}
label={t`Start date/time`}
/>
<FormGroup
name="timezone"
fieldId="schedule-timezone"
helperTextInvalid={timezoneMeta.error || timezoneMessage}
isRequired
validated={timezoneValidatedStatus}
label={t`Local time zone`}
helperText={timezoneMessage}
labelIcon={<Popover content={helpText.localTimeZone(config)} />}
>
<AnsibleSelect
id="schedule-timezone"
data={zoneOptions}
{...timezone}
onChange={warnLinkedTZ}
/>
</FormGroup>
<FormGroup
name="frequency"
fieldId="schedule-requency"
helperTextInvalid={frequencyMeta.error}
isRequired
validated={
!frequencyMeta.touched || !frequencyMeta.error ? 'default' : 'error'
}
label={t`Run frequency`}
>
<AnsibleSelect
id="schedule-frequency"
data={[
{ value: 'none', key: 'none', label: t`None (run once)` },
{ value: 'minute', key: 'minute', label: t`Minute` },
{ value: 'hour', key: 'hour', label: t`Hour` },
{ value: 'day', key: 'day', label: t`Day` },
{ value: 'week', key: 'week', label: t`Week` },
{ value: 'month', key: 'month', label: t`Month` },
{ value: 'year', key: 'year', label: t`Year` },
]}
{...frequency}
/>
</FormGroup>
{hasDaysToKeepField ? (
<FormField
id="schedule-days-to-keep"
label={t`Days of Data to Keep`}
name="daysToKeep"
type="number"
validate={required(null)}
isRequired
/>
) : null}
{frequency.value !== 'none' && (
<SubFormLayout>
<Title size="md" headingLevel="h4">
{t`Frequency Details`}
</Title>
<FormColumnLayout>
<FrequencyDetailSubform />
</FormColumnLayout>
</SubFormLayout>
)}
</>
);
}
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 (
<Form autoComplete="off">
<Alert
variant="danger"
isInline
ouiaId="form-submit-error-alert"
title={t`Complex schedules are not supported in the UI yet, please use the API to manage this schedule.`}
/>
<b>{t`Schedule Rules`}:</b>
<pre css="white-space: pre; font-family: var(--pf-global--FontFamily--monospace)">
{schedule.rrule}
</pre>
<ActionGroup>
<Button
ouiaId="schedule-form-cancel-button"
aria-label={t`Cancel`}
variant="secondary"
type="button"
onClick={handleCancel}
>
{t`Cancel`}
</Button>
</ActionGroup>
</Form>
<UnsupportedScheduleForm
schedule={schedule}
handleCancel={handleCancel}
/>
);
}
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 <ContentLoading />;
}
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 (
<Config>
{() => (
<Formik
initialValues={Object.assign(initialValues, overriddenValues)}
initialValues={{
...initialValues,
...overriddenValues,
frequencyOptions: {
...initialValues.frequencyOptions,
...overriddenValues.frequencyOptions,
},
exceptionOptions: {
...initialValues.exceptionOptions,
...overriddenValues.exceptionOptions,
},
}}
onSubmit={(values) => {
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) => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>

View File

@ -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('<ScheduleForm />', () => {
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('<ScheduleForm />', () => {
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('<ScheduleForm />', () => {
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('<ScheduleForm />', () => {
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('<ScheduleForm />', () => {
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('<ScheduleForm />', () => {
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('<ScheduleForm />', () => {
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('<ScheduleForm />', () => {
});
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('<ScheduleForm />', () => {
});
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('<ScheduleForm />', () => {
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('<ScheduleForm />', () => {
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('<ScheduleForm />', () => {
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('<ScheduleForm />', () => {
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('<ScheduleForm />', () => {
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('<ScheduleForm />', () => {
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('<ScheduleForm />', () => {
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);
});
});

View File

@ -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 (
<>
<FormField
id="schedule-name"
label={t`Name`}
name="name"
type="text"
validate={required(null)}
isRequired
/>
<FormField
id="schedule-description"
label={t`Description`}
name="description"
type="text"
/>
<DateTimePicker
dateFieldName="startDate"
timeFieldName="startTime"
label={t`Start date/time`}
/>
<FormGroup
name="timezone"
fieldId="schedule-timezone"
helperTextInvalid={timezoneMeta.error || timezoneMessage}
isRequired
validated={timezoneValidatedStatus}
label={t`Local time zone`}
helperText={timezoneMessage}
labelIcon={<Popover content={helpText.localTimeZone(config)} />}
>
<AnsibleSelect
id="schedule-timezone"
data={zoneOptions}
{...timezone}
onChange={warnLinkedTZ}
/>
</FormGroup>
<FormGroup
name="frequency"
fieldId="schedule-frequency"
helperTextInvalid={frequencyMeta.error}
validated={
!frequencyMeta.touched || !frequencyMeta.error ? 'default' : 'error'
}
label={t`Repeat frequency`}
>
<FrequencySelect
id="schedule-frequency"
onChange={updateFrequency(frequencyHelper.setValue)}
value={frequency.value}
placeholderText={
frequency.value.length ? t`Select frequency` : t`None (run once)`
}
onBlur={frequencyHelper.setTouched}
>
<SelectClearOption value="none">{t`None (run once)`}</SelectClearOption>
<SelectOption value="minute">{t`Minute`}</SelectOption>
<SelectOption value="hour">{t`Hour`}</SelectOption>
<SelectOption value="day">{t`Day`}</SelectOption>
<SelectOption value="week">{t`Week`}</SelectOption>
<SelectOption value="month">{t`Month`}</SelectOption>
<SelectOption value="year">{t`Year`}</SelectOption>
</FrequencySelect>
</FormGroup>
{hasDaysToKeepField ? (
<FormField
id="schedule-days-to-keep"
label={t`Days of Data to Keep`}
name="daysToKeep"
type="number"
validate={required(null)}
isRequired
/>
) : null}
{frequency.value.length ? (
<SubFormLayout>
<Title size="md" headingLevel="h4">
{t`Frequency Details`}
</Title>
{frequency.value.map((val) => (
<FormColumnLayout key={val} stacked>
<FrequencyDetailSubform
frequency={val}
prefix={`frequencyOptions.${val}`}
/>
</FormColumnLayout>
))}
{/* <Title size="md" headingLevel="h4">{t`Exceptions`}</Title>
<FormGroup
name="exceptions"
fieldId="exception-frequency"
helperTextInvalid={exceptionFrequencyMeta.error}
validated={
!exceptionFrequencyMeta.touched || !exceptionFrequencyMeta.error
? 'default'
: 'error'
}
label={t`Add exceptions`}
>
<FrequencySelect
variant={SelectVariant.checkbox}
onChange={exceptionFrequencyHelper.setValue}
value={exceptionFrequency.value}
placeholderText={t`None`}
onBlur={exceptionFrequencyHelper.setTouched}
>
<SelectClearOption value="none">{t`None`}</SelectClearOption>
<SelectOption value="minute">{t`Minute`}</SelectOption>
<SelectOption value="hour">{t`Hour`}</SelectOption>
<SelectOption value="day">{t`Day`}</SelectOption>
<SelectOption value="week">{t`Week`}</SelectOption>
<SelectOption value="month">{t`Month`}</SelectOption>
<SelectOption value="year">{t`Year`}</SelectOption>
</FrequencySelect>
</FormGroup>
{exceptionFrequency.value.map((val) => (
<FormColumnLayout key={val} stacked>
<FrequencyDetailSubform
frequency={val}
prefix={`exceptionOptions.${val}`}
/>
</FormColumnLayout>
))} */}
</SubFormLayout>
) : null}
</>
);
}

View File

@ -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,
},

View File

@ -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 (
<Form autoComplete="off">
<Alert
variant="danger"
isInline
ouiaId="form-submit-error-alert"
title={t`This schedule uses complex rules that are not supported in the
UI. Please use the API to manage this schedule.`}
/>
<b>{t`Schedule Rules`}:</b>
<pre css="white-space: pre; font-family: var(--pf-global--FontFamily--monospace)">
{schedule.rrule.split(' ').join('\n')}
</pre>
<ActionGroup>
<Button
ouiaId="schedule-form-cancel-button"
aria-label={t`Cancel`}
variant="secondary"
type="button"
onClick={handleCancel}
>
{t`Cancel`}
</Button>
</ActionGroup>
</Form>
);
}

View File

@ -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})`);
}
}

View File

@ -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;
}

View File

@ -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`);
});
});

View File

@ -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;
}

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -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;
};
}