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
23 changed files with 2273 additions and 920 deletions

View File

@@ -24,6 +24,7 @@ const CardBody = styled(PFCardBody)`
const Expandable = styled(PFExpandable)` const Expandable = styled(PFExpandable)`
text-align: left; text-align: left;
max-width: 75vw;
& .pf-c-expandable__toggle { & .pf-c-expandable__toggle {
padding-left: 10px; padding-left: 10px;
@@ -54,7 +55,7 @@ function ErrorDetail({ error }) {
{response?.config?.method.toUpperCase()} {response?.config?.url}{' '} {response?.config?.method.toUpperCase()} {response?.config?.url}{' '}
<strong>{response?.status}</strong> <strong>{response?.status}</strong>
</CardBody> </CardBody>
<CardBody> <CardBody css="max-width: 70vw">
{Array.isArray(message) ? ( {Array.isArray(message) ? (
<ul> <ul>
{message.map((m) => {message.map((m) =>
@@ -70,9 +71,16 @@ function ErrorDetail({ error }) {
}; };
const renderStack = () => ( 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 ( return (

View File

@@ -10,6 +10,11 @@ export const FormColumnLayout = styled.div`
@media (min-width: 1210px) { @media (min-width: 1210px) {
grid-template-columns: repeat(3, 1fr); 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` export const FormFullWidthLayout = styled.div`

View File

@@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { func, shape } from 'prop-types'; import { func, shape } from 'prop-types';
import { useHistory, useLocation } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import { RRule } from 'rrule';
import { Card } from '@patternfly/react-core'; import { Card } from '@patternfly/react-core';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import { parseVariableField } from 'util/yaml'; import { parseVariableField } from 'util/yaml';
@@ -12,7 +11,7 @@ import mergeExtraVars from 'util/prompt/mergeExtraVars';
import getSurveyValues from 'util/prompt/getSurveyValues'; import getSurveyValues from 'util/prompt/getSurveyValues';
import { getAddedAndRemoved } from 'util/lists'; import { getAddedAndRemoved } from 'util/lists';
import ScheduleForm from '../shared/ScheduleForm'; import ScheduleForm from '../shared/ScheduleForm';
import buildRuleObj from '../shared/buildRuleObj'; import buildRuleSet from '../shared/buildRuleSet';
import { CardBody } from '../../Card'; import { CardBody } from '../../Card';
function ScheduleAdd({ function ScheduleAdd({
@@ -36,21 +35,12 @@ function ScheduleAdd({
) => { ) => {
const { const {
inventory, inventory,
extra_vars,
originalCredentials,
end,
frequency, frequency,
interval, frequencyOptions,
exceptionFrequency,
exceptionOptions,
timezone, timezone,
occurrences,
runOn,
runOnTheDay,
runOnTheMonth,
runOnDayMonth,
runOnDayNumber,
runOnTheOccurrence,
credentials, credentials,
daysOfWeek,
...submitValues ...submitValues
} = values; } = values;
const { added } = getAddedAndRemoved( const { added } = getAddedAndRemoved(
@@ -83,11 +73,13 @@ function ScheduleAdd({
} }
try { try {
const rule = new RRule(buildRuleObj(values)); const ruleSet = buildRuleSet(values);
const requestData = { const requestData = {
...submitValues, ...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 (Object.keys(values).includes('daysToKeep')) {
if (requestData.extra_data) { if (requestData.extra_data) {
@@ -98,10 +90,6 @@ function ScheduleAdd({
}); });
} }
} }
delete requestData.startDate;
delete requestData.startTime;
delete requestData.endDate;
delete requestData.endTime;
const { const {
data: { id: scheduleId }, data: { id: scheduleId },

View File

@@ -80,9 +80,7 @@ describe('<ScheduleAdd />', () => {
await act(async () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'never', frequency: [],
frequency: 'none',
interval: 1,
name: 'Run once schedule', name: 'Run once schedule',
startDate: '2020-03-25', startDate: '2020-03-25',
startTime: '10:00 AM', 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 () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'after', frequency: ['minute'],
frequency: 'minute', frequencyOptions: {
interval: 10, minute: {
end: 'after',
interval: 10,
occurrences: 10,
},
},
name: 'Run every 10 minutes 10 times', name: 'Run every 10 minutes 10 times',
occurrences: 10,
startDate: '2020-03-25', startDate: '2020-03-25',
startTime: '10:30 AM', startTime: '10:30 AM',
timezone: 'America/New_York', timezone: 'America/New_York',
@@ -125,11 +127,15 @@ describe('<ScheduleAdd />', () => {
await act(async () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'onDate', frequency: ['hour'],
endDate: '2020-03-26', frequencyOptions: {
endTime: '10:45 AM', hour: {
frequency: 'hour', end: 'onDate',
interval: 1, interval: 1,
endDate: '2020-03-26',
endTime: '10:45 AM',
},
},
name: 'Run every hour until date', name: 'Run every hour until date',
startDate: '2020-03-25', startDate: '2020-03-25',
startTime: '10:45 AM', startTime: '10:45 AM',
@@ -141,7 +147,7 @@ describe('<ScheduleAdd />', () => {
name: 'Run every hour until date', name: 'Run every hour until date',
extra_data: {}, extra_data: {},
rrule: 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 () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'never', frequency: ['day'],
frequency: 'day', frequencyOptions: {
interval: 1, day: {
end: 'never',
interval: 1,
},
},
name: 'Run daily', name: 'Run daily',
startDate: '2020-03-25', startDate: '2020-03-25',
startTime: '10:45 AM', startTime: '10:45 AM',
@@ -170,13 +180,17 @@ describe('<ScheduleAdd />', () => {
test('Successfully creates a schedule with weekly repeat frequency on mon/wed/fri', async () => { test('Successfully creates a schedule with weekly repeat frequency on mon/wed/fri', async () => {
await act(async () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
daysOfWeek: [RRule.MO, RRule.WE, RRule.FR],
description: 'test description', description: 'test description',
end: 'never', frequency: ['week'],
frequency: 'week', frequencyOptions: {
interval: 1, week: {
end: 'never',
interval: 1,
occurrences: 1,
daysOfWeek: [RRule.MO, RRule.WE, RRule.FR],
},
},
name: 'Run weekly on mon/wed/fri', name: 'Run weekly on mon/wed/fri',
occurrences: 1,
startDate: '2020-03-25', startDate: '2020-03-25',
startTime: '10:45 AM', startTime: '10:45 AM',
timezone: 'America/New_York', timezone: 'America/New_York',
@@ -194,13 +208,17 @@ describe('<ScheduleAdd />', () => {
await act(async () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'never', frequency: ['month'],
frequency: 'month', frequencyOptions: {
interval: 1, month: {
end: 'never',
occurrences: 1,
interval: 1,
runOn: 'day',
runOnDayNumber: 1,
},
},
name: 'Run on the first day of the month', name: 'Run on the first day of the month',
occurrences: 1,
runOn: 'day',
runOnDayNumber: 1,
startTime: '10:45 AM', startTime: '10:45 AM',
startDate: '2020-04-01', startDate: '2020-04-01',
timezone: 'America/New_York', timezone: 'America/New_York',
@@ -219,16 +237,20 @@ describe('<ScheduleAdd />', () => {
await act(async () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'never', frequency: ['month'],
endDate: '2020-03-26', frequencyOptions: {
endTime: '11:00 AM', month: {
frequency: 'month', end: 'never',
interval: 1, 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', name: 'Run monthly on the last Tuesday',
occurrences: 1,
runOn: 'the',
runOnTheDay: 'tuesday',
runOnTheOccurrence: -1,
startDate: '2020-03-31', startDate: '2020-03-31',
startTime: '11:00 AM', startTime: '11:00 AM',
timezone: 'America/New_York', 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', '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 () => { test('Successfully creates a schedule with yearly repeat frequency on the first day of March', async () => {
await act(async () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'never', frequency: ['year'],
frequency: 'year', frequencyOptions: {
interval: 1, year: {
end: 'never',
interval: 1,
occurrences: 1,
runOn: 'day',
runOnDayMonth: 3,
runOnDayNumber: 1,
},
},
name: 'Yearly on the first day of March', name: 'Yearly on the first day of March',
occurrences: 1,
runOn: 'day',
runOnDayMonth: 3,
runOnDayNumber: 1,
startDate: '2020-03-01', startDate: '2020-03-01',
startTime: '12:00 AM', startTime: '12:00 AM',
timezone: 'America/New_York', timezone: 'America/New_York',
@@ -272,15 +299,19 @@ describe('<ScheduleAdd />', () => {
await act(async () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'never', frequency: ['year'],
frequency: 'year', frequencyOptions: {
interval: 1, year: {
end: 'never',
interval: 1,
occurrences: 1,
runOn: 'the',
runOnTheOccurrence: 2,
runOnTheDay: 'friday',
runOnTheMonth: 4,
},
},
name: 'Yearly on the second Friday in April', name: 'Yearly on the second Friday in April',
occurrences: 1,
runOn: 'the',
runOnTheOccurrence: 2,
runOnTheDay: 'friday',
runOnTheMonth: 4,
startDate: '2020-04-10', startDate: '2020-04-10',
startTime: '11:15 AM', startTime: '11:15 AM',
timezone: 'America/New_York', timezone: 'America/New_York',
@@ -299,15 +330,19 @@ describe('<ScheduleAdd />', () => {
await act(async () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'never', frequency: ['year'],
frequency: 'year', frequencyOptions: {
interval: 1, year: {
end: 'never',
interval: 1,
occurrences: 1,
runOn: 'the',
runOnTheOccurrence: 1,
runOnTheDay: 'weekday',
runOnTheMonth: 10,
},
},
name: 'Yearly on the first weekday in October', name: 'Yearly on the first weekday in October',
occurrences: 1,
runOn: 'the',
runOnTheOccurrence: 1,
runOnTheDay: 'weekday',
runOnTheMonth: 10,
startDate: '2020-04-10', startDate: '2020-04-10',
startTime: '11:15 AM', startTime: '11:15 AM',
timezone: 'America/New_York', timezone: 'America/New_York',
@@ -376,17 +411,7 @@ describe('<ScheduleAdd />', () => {
await act(async () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
name: 'Schedule', name: 'Schedule',
end: 'never', frequency: [],
endDate: '2021-01-29',
endTime: '2:15 PM',
frequency: 'none',
occurrences: 1,
runOn: 'day',
runOnDayMonth: 1,
runOnDayNumber: 1,
runOnTheDay: 'sunday',
runOnTheMonth: 1,
runOnTheOccurrence: 1,
skip_tags: '', skip_tags: '',
inventory: { name: 'inventory', id: 45 }, inventory: { name: 'inventory', id: 45 },
credentials: [ credentials: [
@@ -405,7 +430,7 @@ describe('<ScheduleAdd />', () => {
inventory: 45, inventory: 45,
name: 'Schedule', name: 'Schedule',
rrule: 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: '', skip_tags: '',
}); });
expect(SchedulesAPI.associateCredential).toBeCalledWith(3, 10); expect(SchedulesAPI.associateCredential).toBeCalledWith(3, 10);
@@ -462,9 +487,7 @@ describe('<ScheduleAdd />', () => {
await act(async () => { await act(async () => {
scheduleSurveyWrapper.find('Formik').invoke('onSubmit')({ scheduleSurveyWrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'never', frequency: [],
frequency: 'none',
interval: 1,
name: 'Run once schedule', name: 'Run once schedule',
startDate: '2020-03-25', startDate: '2020-03-25',
startTime: '10:00 AM', 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 'styled-components/macro';
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { Link, useHistory, useLocation } from 'react-router-dom'; import { Link, useHistory, useLocation } from 'react-router-dom';
import { RRule, rrulestr } from 'rrule';
import styled from 'styled-components'; import styled from 'styled-components';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -12,6 +11,8 @@ import useRequest, { useDismissableError } from 'hooks/useRequest';
import { JobTemplatesAPI, SchedulesAPI, WorkflowJobTemplatesAPI } from 'api'; import { JobTemplatesAPI, SchedulesAPI, WorkflowJobTemplatesAPI } from 'api';
import { parseVariableField, jsonToYaml } from 'util/yaml'; import { parseVariableField, jsonToYaml } from 'util/yaml';
import { useConfig } from 'contexts/Config'; import { useConfig } from 'contexts/Config';
import parseRuleObj from '../shared/parseRuleObj';
import FrequencyDetails from './FrequencyDetails';
import AlertModal from '../../AlertModal'; import AlertModal from '../../AlertModal';
import { CardBody, CardActionsRow } from '../../Card'; import { CardBody, CardActionsRow } from '../../Card';
import ContentError from '../../ContentError'; import ContentError from '../../ContentError';
@@ -41,6 +42,26 @@ const PromptTitle = styled(Title)`
const PromptDetailList = styled(DetailList)` const PromptDetailList = styled(DetailList)`
padding: 0px 20px; 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 }) { function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) {
const { const {
id, id,
@@ -132,19 +153,18 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) {
fetchCredentialsAndPreview(); fetchCredentialsAndPreview();
}, [fetchCredentialsAndPreview]); }, [fetchCredentialsAndPreview]);
const rule = rrulestr(rrule); const frequencies = {
let repeatFrequency = minute: t`Minute`,
rule.options.freq === RRule.MINUTELY && dtstart === dtend hour: t`Hour`,
? t`None (Run Once)` day: t`Day`,
: rule.toText().replace(/^\w/, (c) => c.toUpperCase()); week: t`Week`,
// We should allow rrule tot handle this issue, and they have in version 2.6.8. month: t`Month`,
// (https://github.com/jakubroztocil/rrule/commit/ab9c564a83de2f9688d6671f2a6df273ceb902bf) year: t`Year`,
// However, we are unable to upgrade to that version because that };
// version throws and unexpected warning. const { frequency, frequencyOptions } = parseRuleObj(schedule);
// (https://github.com/jakubroztocil/rrule/issues/427) const repeatFrequency = frequency.length
if (repeatFrequency.split(' ')[1] === 'minutes') { ? frequency.map((f) => frequencies[f]).join(', ')
repeatFrequency = t`Every minute for ${rule.options.count} times`; : t`None (Run Once)`;
}
const { const {
ask_credential_on_launch, ask_credential_on_launch,
@@ -268,6 +288,24 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) {
helpText={helpText.localTimeZone(config)} helpText={helpText.localTimeZone(config)}
/> />
<Detail label={t`Repeat Frequency`} value={repeatFrequency} /> <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 ? ( {hasDaysToKeepField ? (
<Detail label={t`Days of Data to Keep`} value={daysToKeep} /> <Detail label={t`Days of Data to Keep`} value={daysToKeep} />
) : null} ) : null}

View File

@@ -1,7 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import { RRule } from 'rrule';
import { shape } from 'prop-types'; import { shape } from 'prop-types';
import { Card } from '@patternfly/react-core'; import { Card } from '@patternfly/react-core';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
@@ -12,7 +11,7 @@ import { parseVariableField } from 'util/yaml';
import mergeExtraVars from 'util/prompt/mergeExtraVars'; import mergeExtraVars from 'util/prompt/mergeExtraVars';
import getSurveyValues from 'util/prompt/getSurveyValues'; import getSurveyValues from 'util/prompt/getSurveyValues';
import ScheduleForm from '../shared/ScheduleForm'; import ScheduleForm from '../shared/ScheduleForm';
import buildRuleObj from '../shared/buildRuleObj'; import buildRuleSet from '../shared/buildRuleSet';
import { CardBody } from '../../Card'; import { CardBody } from '../../Card';
function ScheduleEdit({ function ScheduleEdit({
@@ -27,7 +26,7 @@ function ScheduleEdit({
const history = useHistory(); const history = useHistory();
const location = useLocation(); const location = useLocation();
const { pathname } = location; const { pathname } = location;
const pathRoot = pathname.substr(0, pathname.indexOf('schedules')); const pathRoot = pathname.substring(0, pathname.indexOf('schedules'));
const handleSubmit = async ( const handleSubmit = async (
values, values,
@@ -38,18 +37,11 @@ function ScheduleEdit({
const { const {
inventory, inventory,
credentials = [], credentials = [],
end,
frequency, frequency,
interval, frequencyOptions,
exceptionFrequency,
exceptionOptions,
timezone, timezone,
occurences,
runOn,
runOnTheDay,
runOnTheMonth,
runOnDayMonth,
runOnDayNumber,
runOnTheOccurence,
daysOfWeek,
...submitValues ...submitValues
} = values; } = values;
const { added, removed } = getAddedAndRemoved( const { added, removed } = getAddedAndRemoved(
@@ -91,15 +83,13 @@ function ScheduleEdit({
} }
try { try {
const rule = new RRule(buildRuleObj(values)); const ruleSet = buildRuleSet(values);
const requestData = { const requestData = {
...submitValues, ...submitValues,
rrule: rule.toString().replace(/\n/g, ' '), rrule: ruleSet.toString().replace(/\n/g, ' '),
}; };
delete requestData.startDate; delete requestData.startDate;
delete requestData.startTime; delete requestData.startTime;
delete requestData.endDate;
delete requestData.endTime;
if (Object.keys(values).includes('daysToKeep')) { if (Object.keys(values).includes('daysToKeep')) {
if (!requestData.extra_data) { if (!requestData.extra_data) {

View File

@@ -195,9 +195,7 @@ describe('<ScheduleEdit />', () => {
await act(async () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'never', frequency: [],
frequency: 'none',
interval: 1,
name: 'Run once schedule', name: 'Run once schedule',
startDate: '2020-03-25', startDate: '2020-03-25',
startTime: '10:00 AM', startTime: '10:00 AM',
@@ -218,11 +216,15 @@ describe('<ScheduleEdit />', () => {
await act(async () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'after', frequency: ['minute'],
frequency: 'minute', frequencyOptions: {
interval: 10, minute: {
end: 'after',
interval: 10,
occurrences: 10,
},
},
name: 'Run every 10 minutes 10 times', name: 'Run every 10 minutes 10 times',
occurrences: 10,
startDate: '2020-03-25', startDate: '2020-03-25',
startTime: '10:30 AM', startTime: '10:30 AM',
timezone: 'America/New_York', timezone: 'America/New_York',
@@ -232,7 +234,6 @@ describe('<ScheduleEdit />', () => {
description: 'test description', description: 'test description',
name: 'Run every 10 minutes 10 times', name: 'Run every 10 minutes 10 times',
extra_data: {}, extra_data: {},
occurrences: 10,
rrule: rrule:
'DTSTART;TZID=America/New_York:20200325T103000 RRULE:INTERVAL=10;FREQ=MINUTELY;COUNT=10', 'DTSTART;TZID=America/New_York:20200325T103000 RRULE:INTERVAL=10;FREQ=MINUTELY;COUNT=10',
}); });
@@ -242,11 +243,15 @@ describe('<ScheduleEdit />', () => {
await act(async () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'onDate', frequency: ['hour'],
endDate: '2020-03-26', frequencyOptions: {
endTime: '10:45 AM', hour: {
frequency: 'hour', end: 'onDate',
interval: 1, endDate: '2020-03-26',
endTime: '10:45 AM',
interval: 1,
},
},
name: 'Run every hour until date', name: 'Run every hour until date',
startDate: '2020-03-25', startDate: '2020-03-25',
startTime: '10:45 AM', startTime: '10:45 AM',
@@ -259,7 +264,7 @@ describe('<ScheduleEdit />', () => {
name: 'Run every hour until date', name: 'Run every hour until date',
extra_data: {}, extra_data: {},
rrule: 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 () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'never', frequency: ['day'],
frequency: 'day', frequencyOptions: {
interval: 1, day: {
end: 'never',
interval: 1,
},
},
name: 'Run daily', name: 'Run daily',
startDate: '2020-03-25', startDate: '2020-03-25',
startTime: '10:45 AM', startTime: '10:45 AM',
@@ -284,16 +293,21 @@ describe('<ScheduleEdit />', () => {
'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=DAILY', '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 () => { test('Successfully creates a schedule with weekly repeat frequency on mon/wed/fri', async () => {
await act(async () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
daysOfWeek: [RRule.MO, RRule.WE, RRule.FR],
description: 'test description', description: 'test description',
end: 'never', frequency: ['week'],
frequency: 'week', frequencyOptions: {
interval: 1, week: {
end: 'never',
daysOfWeek: [RRule.MO, RRule.WE, RRule.FR],
interval: 1,
occurrences: 1,
},
},
name: 'Run weekly on mon/wed/fri', name: 'Run weekly on mon/wed/fri',
occurrences: 1,
startDate: '2020-03-25', startDate: '2020-03-25',
startTime: '10:45 AM', startTime: '10:45 AM',
timezone: 'America/New_York', timezone: 'America/New_York',
@@ -303,7 +317,6 @@ describe('<ScheduleEdit />', () => {
description: 'test description', description: 'test description',
name: 'Run weekly on mon/wed/fri', name: 'Run weekly on mon/wed/fri',
extra_data: {}, extra_data: {},
occurrences: 1,
rrule: `DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=${RRule.MO},${RRule.WE},${RRule.FR}`, 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 () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'never', frequency: ['month'],
frequency: 'month', frequencyOptions: {
interval: 1, month: {
end: 'never',
interval: 1,
occurrences: 1,
runOn: 'day',
runOnDayNumber: 1,
},
},
name: 'Run on the first day of the month', name: 'Run on the first day of the month',
occurrences: 1,
runOn: 'day',
runOnDayNumber: 1,
startDate: '2020-04-01', startDate: '2020-04-01',
startTime: '10:45 AM', startTime: '10:45 AM',
timezone: 'America/New_York', timezone: 'America/New_York',
@@ -328,7 +345,6 @@ describe('<ScheduleEdit />', () => {
description: 'test description', description: 'test description',
name: 'Run on the first day of the month', name: 'Run on the first day of the month',
extra_data: {}, extra_data: {},
occurrences: 1,
rrule: rrule:
'DTSTART;TZID=America/New_York:20200401T104500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYMONTHDAY=1', 'DTSTART;TZID=America/New_York:20200401T104500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYMONTHDAY=1',
}); });
@@ -338,15 +354,20 @@ describe('<ScheduleEdit />', () => {
await act(async () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'never', frequency: ['month'],
endDateTime: '2020-03-26T11:00:00', frequencyOptions: {
frequency: 'month', month: {
interval: 1, 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', name: 'Run monthly on the last Tuesday',
occurrences: 1,
runOn: 'the',
runOnTheDay: 'tuesday',
runOnTheOccurrence: -1,
startDate: '2020-03-31', startDate: '2020-03-31',
startTime: '11:00 AM', startTime: '11:00 AM',
timezone: 'America/New_York', timezone: 'America/New_York',
@@ -354,11 +375,8 @@ describe('<ScheduleEdit />', () => {
}); });
expect(SchedulesAPI.update).toHaveBeenCalledWith(27, { expect(SchedulesAPI.update).toHaveBeenCalledWith(27, {
description: 'test description', description: 'test description',
endDateTime: '2020-03-26T11:00:00',
name: 'Run monthly on the last Tuesday', name: 'Run monthly on the last Tuesday',
extra_data: {}, extra_data: {},
occurrences: 1,
runOnTheOccurrence: -1,
rrule: rrule:
'DTSTART;TZID=America/New_York:20200331T110000 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=TU', 'DTSTART;TZID=America/New_York:20200331T110000 RRULE:INTERVAL=1;FREQ=MONTHLY;BYSETPOS=-1;BYDAY=TU',
}); });
@@ -368,14 +386,18 @@ describe('<ScheduleEdit />', () => {
await act(async () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'never', frequency: ['year'],
frequency: 'year', frequencyOptions: {
interval: 1, year: {
end: 'never',
interval: 1,
occurrences: 1,
runOn: 'day',
runOnDayMonth: 3,
runOnDayNumber: 1,
},
},
name: 'Yearly on the first day of March', name: 'Yearly on the first day of March',
occurrences: 1,
runOn: 'day',
runOnDayMonth: 3,
runOnDayNumber: 1,
startTime: '12:00 AM', startTime: '12:00 AM',
startDate: '2020-03-01', startDate: '2020-03-01',
timezone: 'America/New_York', timezone: 'America/New_York',
@@ -385,7 +407,6 @@ describe('<ScheduleEdit />', () => {
description: 'test description', description: 'test description',
name: 'Yearly on the first day of March', name: 'Yearly on the first day of March',
extra_data: {}, extra_data: {},
occurrences: 1,
rrule: rrule:
'DTSTART;TZID=America/New_York:20200301T000000 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=1', 'DTSTART;TZID=America/New_York:20200301T000000 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=1',
}); });
@@ -395,15 +416,19 @@ describe('<ScheduleEdit />', () => {
await act(async () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'never', frequency: ['year'],
frequency: 'year', frequencyOptions: {
interval: 1, year: {
end: 'never',
interval: 1,
occurrences: 1,
runOn: 'the',
runOnTheOccurrence: 2,
runOnTheDay: 'friday',
runOnTheMonth: 4,
},
},
name: 'Yearly on the second Friday in April', name: 'Yearly on the second Friday in April',
occurrences: 1,
runOn: 'the',
runOnTheOccurrence: 2,
runOnTheDay: 'friday',
runOnTheMonth: 4,
startTime: '11:15 AM', startTime: '11:15 AM',
startDate: '2020-04-10', startDate: '2020-04-10',
timezone: 'America/New_York', timezone: 'America/New_York',
@@ -413,8 +438,6 @@ describe('<ScheduleEdit />', () => {
description: 'test description', description: 'test description',
name: 'Yearly on the second Friday in April', name: 'Yearly on the second Friday in April',
extra_data: {}, extra_data: {},
occurrences: 1,
runOnTheOccurrence: 2,
rrule: rrule:
'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=2;BYDAY=FR;BYMONTH=4', '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 () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'never', frequency: ['year'],
frequency: 'year', frequencyOptions: {
interval: 1, year: {
end: 'never',
interval: 1,
occurrences: 1,
runOn: 'the',
runOnTheOccurrence: 1,
runOnTheDay: 'weekday',
runOnTheMonth: 10,
},
},
name: 'Yearly on the first weekday in October', name: 'Yearly on the first weekday in October',
occurrences: 1,
runOn: 'the',
runOnTheOccurrence: 1,
runOnTheDay: 'weekday',
runOnTheMonth: 10,
startTime: '11:15 AM', startTime: '11:15 AM',
startDate: '2020-04-10', startDate: '2020-04-10',
timezone: 'America/New_York', timezone: 'America/New_York',
@@ -442,8 +469,6 @@ describe('<ScheduleEdit />', () => {
description: 'test description', description: 'test description',
name: 'Yearly on the first weekday in October', name: 'Yearly on the first weekday in October',
extra_data: {}, extra_data: {},
occurrences: 1,
runOnTheOccurrence: 1,
rrule: rrule:
'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=1;BYDAY=MO,TU,WE,TH,FR;BYMONTH=10', '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 () => { await act(async () => {
wrapper.find('Formik').invoke('onSubmit')({ wrapper.find('Formik').invoke('onSubmit')({
name: mockSchedule.name, name: mockSchedule.name,
end: 'never', frequency: [],
endDate: '2021-01-29',
endTime: '2:15 PM',
frequency: 'none',
occurrences: 1,
runOn: 'day',
runOnDayMonth: 1,
runOnDayNumber: 1,
runOnTheDay: 'sunday',
runOnTheMonth: 1,
runOnTheOccurrence: 1,
skip_tags: '', skip_tags: '',
startDate: '2021-01-28', startDate: '2021-01-28',
startTime: '2:15 PM', startTime: '2:15 PM',
@@ -549,10 +564,8 @@ describe('<ScheduleEdit />', () => {
expect(SchedulesAPI.update).toBeCalledWith(27, { expect(SchedulesAPI.update).toBeCalledWith(27, {
extra_data: {}, extra_data: {},
name: 'mock schedule', name: 'mock schedule',
occurrences: 1,
runOnTheOccurrence: 1,
rrule: 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: '', skip_tags: '',
}); });
expect(SchedulesAPI.disassociateCredential).toBeCalledWith(27, 75); expect(SchedulesAPI.disassociateCredential).toBeCalledWith(27, 75);
@@ -621,8 +634,6 @@ describe('<ScheduleEdit />', () => {
startDateTime: undefined, startDateTime: undefined,
description: '', description: '',
extra_data: {}, extra_data: {},
occurrences: 1,
runOnTheOccurrence: 1,
name: 'foo', name: 'foo',
inventory: 702, inventory: 702,
rrule: rrule:
@@ -723,9 +734,8 @@ describe('<ScheduleEdit />', () => {
await act(async () => { await act(async () => {
scheduleSurveyWrapper.find('Formik').invoke('onSubmit')({ scheduleSurveyWrapper.find('Formik').invoke('onSubmit')({
description: 'test description', description: 'test description',
end: 'never', frequency: [],
frequency: 'none', frequencyOptions: {},
interval: 1,
name: 'Run once schedule', name: 'Run once schedule',
startDate: '2020-03-25', startDate: '2020-03-25',
startTime: '10:00 AM', startTime: '10:00 AM',

View File

@@ -16,11 +16,11 @@ const DateTimeGroup = styled.span`
`; `;
function DateTimePicker({ dateFieldName, timeFieldName, label }) { function DateTimePicker({ dateFieldName, timeFieldName, label }) {
const [dateField, dateMeta, dateHelpers] = useField({ const [dateField, dateMeta, dateHelpers] = useField({
name: `${dateFieldName}`, name: dateFieldName,
validate: combine([required(null), isValidDate]), validate: combine([required(null), isValidDate]),
}); });
const [timeField, timeMeta, timeHelpers] = useField({ const [timeField, timeMeta, timeHelpers] = useField({
name: `${timeFieldName}`, name: timeFieldName,
validate: combine([required(null), validateTime()]), validate: combine([required(null), validateTime()]),
}); });

View File

@@ -11,7 +11,7 @@ import {
Radio, Radio,
TextInput, TextInput,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { required } from 'util/validators'; import { required, requiredPositiveInteger } from 'util/validators';
import AnsibleSelect from '../../AnsibleSelect'; import AnsibleSelect from '../../AnsibleSelect';
import FormField from '../../FormField'; import FormField from '../../FormField';
import DateTimePicker from './DateTimePicker'; import DateTimePicker from './DateTimePicker';
@@ -45,65 +45,50 @@ const Checkbox = styled(_Checkbox)`
} }
`; `;
export function requiredPositiveInteger() { const FrequencyDetailSubform = ({ frequency, prefix }) => {
return (value) => { const id = prefix.replace('.', '-');
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 [runOnDayMonth] = useField({ const [runOnDayMonth] = useField({
name: 'runOnDayMonth', name: `${prefix}.runOnDayMonth`,
}); });
const [runOnDayNumber] = useField({ const [runOnDayNumber] = useField({
name: 'runOnDayNumber', name: `${prefix}.runOnDayNumber`,
}); });
const [runOnTheOccurrence] = useField({ const [runOnTheOccurrence] = useField({
name: 'runOnTheOccurrence', name: `${prefix}.runOnTheOccurrence`,
}); });
const [runOnTheDay] = useField({ const [runOnTheDay] = useField({
name: 'runOnTheDay', name: `${prefix}.runOnTheDay`,
}); });
const [runOnTheMonth] = useField({ const [runOnTheMonth] = useField({
name: 'runOnTheMonth', name: `${prefix}.runOnTheMonth`,
}); });
const [startDate] = useField('startDate'); const [startDate] = useField(`${prefix}.startDate`);
const [{ name: dateFieldName }] = useField('endDate');
const [{ name: timeFieldName }] = useField('endTime');
const [daysOfWeek, daysOfWeekMeta, daysOfWeekHelpers] = useField({ const [daysOfWeek, daysOfWeekMeta, daysOfWeekHelpers] = useField({
name: 'daysOfWeek', name: `${prefix}.daysOfWeek`,
validate: required(t`Select a value for this field`), validate: (val) => {
if (frequency === 'week') {
return required(t`Select a value for this field`)(val?.length > 0);
}
return undefined;
},
}); });
const [end, endMeta] = useField({ const [end, endMeta] = useField({
name: 'end', name: `${prefix}.end`,
validate: required(t`Select a value for this field`), validate: required(t`Select a value for this field`),
}); });
const [interval, intervalMeta] = useField({ const [interval, intervalMeta] = useField({
name: 'interval', name: `${prefix}.interval`,
validate: requiredPositiveInteger(), validate: requiredPositiveInteger(),
}); });
const [runOn, runOnMeta] = useField({ const [runOn, runOnMeta] = useField({
name: 'runOn', name: `${prefix}.runOn`,
validate: required(t`Select a value for this field`), validate: (val) => {
}); if (frequency === 'month' || frequency === 'year') {
const [frequency] = useField({ return required(t`Select a value for this field`)(val);
name: 'frequency', }
}); return undefined;
useField({ },
name: 'occurrences',
validate: requiredPositiveInteger(),
}); });
const monthOptions = [ const monthOptions = [
@@ -170,7 +155,8 @@ const FrequencyDetailSubform = () => {
]; ];
const updateDaysOfWeek = (day, checked) => { const updateDaysOfWeek = (day, checked) => {
const newDaysOfWeek = [...daysOfWeek.value]; const newDaysOfWeek = daysOfWeek.value ? [...daysOfWeek.value] : [];
daysOfWeekHelpers.setTouched(true);
if (checked) { if (checked) {
newDaysOfWeek.push(day); newDaysOfWeek.push(day);
daysOfWeekHelpers.setValue(newDaysOfWeek); 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 getRunEveryLabel = () => {
const intervalValue = interval.value; const intervalValue = interval.value;
switch (frequency.value) { switch (frequency) {
case 'minute': case 'minute':
return <Plural value={intervalValue} one="minute" other="minutes" />; return <Plural value={intervalValue} one="minute" other="minutes" />;
case 'hour': case 'hour':
@@ -202,12 +207,14 @@ const FrequencyDetailSubform = () => {
} }
}; };
/* eslint-disable no-restricted-globals */
return ( return (
<> <>
<p css="grid-column: 1/-1">
<b>{getPeriodLabel()}</b>
</p>
<FormGroup <FormGroup
name="interval" name={`${prefix}.interval`}
fieldId="schedule-run-every" fieldId={`schedule-run-every-${id}`}
helperTextInvalid={intervalMeta.error} helperTextInvalid={intervalMeta.error}
isRequired isRequired
validated={ validated={
@@ -218,7 +225,7 @@ const FrequencyDetailSubform = () => {
<div css="display: flex"> <div css="display: flex">
<TextInput <TextInput
css="margin-right: 10px;" css="margin-right: 10px;"
id="schedule-run-every" id={`schedule-run-every-${id}`}
type="number" type="number"
min="1" min="1"
step="1" step="1"
@@ -230,10 +237,10 @@ const FrequencyDetailSubform = () => {
<RunEveryLabel>{getRunEveryLabel()}</RunEveryLabel> <RunEveryLabel>{getRunEveryLabel()}</RunEveryLabel>
</div> </div>
</FormGroup> </FormGroup>
{frequency?.value === 'week' && ( {frequency === 'week' && (
<FormGroup <FormGroup
name="daysOfWeek" name={`${prefix}.daysOfWeek`}
fieldId="schedule-days-of-week" fieldId={`schedule-days-of-week-${id}`}
helperTextInvalid={daysOfWeekMeta.error} helperTextInvalid={daysOfWeekMeta.error}
isRequired isRequired
validated={ validated={
@@ -246,89 +253,89 @@ const FrequencyDetailSubform = () => {
<div css="display: flex"> <div css="display: flex">
<Checkbox <Checkbox
label={t`Sun`} label={t`Sun`}
isChecked={daysOfWeek.value.includes(RRule.SU)} isChecked={daysOfWeek.value?.includes(RRule.SU)}
onChange={(checked) => { onChange={(checked) => {
updateDaysOfWeek(RRule.SU, checked); updateDaysOfWeek(RRule.SU, checked);
}} }}
aria-label={t`Sunday`} aria-label={t`Sunday`}
id="schedule-days-of-week-sun" id={`schedule-days-of-week-sun-${id}`}
ouiaId="schedule-days-of-week-sun" ouiaId={`schedule-days-of-week-sun-${id}`}
name="daysOfWeek" name={`${prefix}.daysOfWeek`}
/> />
<Checkbox <Checkbox
label={t`Mon`} label={t`Mon`}
isChecked={daysOfWeek.value.includes(RRule.MO)} isChecked={daysOfWeek.value?.includes(RRule.MO)}
onChange={(checked) => { onChange={(checked) => {
updateDaysOfWeek(RRule.MO, checked); updateDaysOfWeek(RRule.MO, checked);
}} }}
aria-label={t`Monday`} aria-label={t`Monday`}
id="schedule-days-of-week-mon" id={`schedule-days-of-week-mon-${id}`}
ouiaId="schedule-days-of-week-mon" ouiaId={`schedule-days-of-week-mon-${id}`}
name="daysOfWeek" name={`${prefix}.daysOfWeek`}
/> />
<Checkbox <Checkbox
label={t`Tue`} label={t`Tue`}
isChecked={daysOfWeek.value.includes(RRule.TU)} isChecked={daysOfWeek.value?.includes(RRule.TU)}
onChange={(checked) => { onChange={(checked) => {
updateDaysOfWeek(RRule.TU, checked); updateDaysOfWeek(RRule.TU, checked);
}} }}
aria-label={t`Tuesday`} aria-label={t`Tuesday`}
id="schedule-days-of-week-tue" id={`schedule-days-of-week-tue-${id}`}
ouiaId="schedule-days-of-week-tue" ouiaId={`schedule-days-of-week-tue-${id}`}
name="daysOfWeek" name={`${prefix}.daysOfWeek`}
/> />
<Checkbox <Checkbox
label={t`Wed`} label={t`Wed`}
isChecked={daysOfWeek.value.includes(RRule.WE)} isChecked={daysOfWeek.value?.includes(RRule.WE)}
onChange={(checked) => { onChange={(checked) => {
updateDaysOfWeek(RRule.WE, checked); updateDaysOfWeek(RRule.WE, checked);
}} }}
aria-label={t`Wednesday`} aria-label={t`Wednesday`}
id="schedule-days-of-week-wed" id={`schedule-days-of-week-wed-${id}`}
ouiaId="schedule-days-of-week-wed" ouiaId={`schedule-days-of-week-wed-${id}`}
name="daysOfWeek" name={`${prefix}.daysOfWeek`}
/> />
<Checkbox <Checkbox
label={t`Thu`} label={t`Thu`}
isChecked={daysOfWeek.value.includes(RRule.TH)} isChecked={daysOfWeek.value?.includes(RRule.TH)}
onChange={(checked) => { onChange={(checked) => {
updateDaysOfWeek(RRule.TH, checked); updateDaysOfWeek(RRule.TH, checked);
}} }}
aria-label={t`Thursday`} aria-label={t`Thursday`}
id="schedule-days-of-week-thu" id={`schedule-days-of-week-thu-${id}`}
ouiaId="schedule-days-of-week-thu" ouiaId={`schedule-days-of-week-thu-${id}`}
name="daysOfWeek" name={`${prefix}.daysOfWeek`}
/> />
<Checkbox <Checkbox
label={t`Fri`} label={t`Fri`}
isChecked={daysOfWeek.value.includes(RRule.FR)} isChecked={daysOfWeek.value?.includes(RRule.FR)}
onChange={(checked) => { onChange={(checked) => {
updateDaysOfWeek(RRule.FR, checked); updateDaysOfWeek(RRule.FR, checked);
}} }}
aria-label={t`Friday`} aria-label={t`Friday`}
id="schedule-days-of-week-fri" id={`schedule-days-of-week-fri-${id}`}
ouiaId="schedule-days-of-week-fri" ouiaId={`schedule-days-of-week-fri-${id}`}
name="daysOfWeek" name={`${prefix}.daysOfWeek`}
/> />
<Checkbox <Checkbox
label={t`Sat`} label={t`Sat`}
isChecked={daysOfWeek.value.includes(RRule.SA)} isChecked={daysOfWeek.value?.includes(RRule.SA)}
onChange={(checked) => { onChange={(checked) => {
updateDaysOfWeek(RRule.SA, checked); updateDaysOfWeek(RRule.SA, checked);
}} }}
aria-label={t`Saturday`} aria-label={t`Saturday`}
id="schedule-days-of-week-sat" id={`schedule-days-of-week-sat-${id}`}
ouiaId="schedule-days-of-week-sat" ouiaId={`schedule-days-of-week-sat-${id}`}
name="daysOfWeek" name={`${prefix}.daysOfWeek`}
/> />
</div> </div>
</FormGroup> </FormGroup>
)} )}
{(frequency?.value === 'month' || frequency?.value === 'year') && {(frequency === 'month' || frequency === 'year') &&
!isNaN(new Date(startDate.value)) && ( !Number.isNaN(new Date(startDate.value)) && (
<FormGroup <FormGroup
name="runOn" name={`${prefix}.runOn`}
fieldId="schedule-run-on" fieldId={`schedule-run-on-${id}`}
helperTextInvalid={runOnMeta.error} helperTextInvalid={runOnMeta.error}
isRequired isRequired
validated={ validated={
@@ -337,11 +344,11 @@ const FrequencyDetailSubform = () => {
label={t`Run on`} label={t`Run on`}
> >
<RunOnRadio <RunOnRadio
id="schedule-run-on-day" id={`schedule-run-on-day-${id}`}
name="runOn" name={`${prefix}.runOn`}
label={ label={
<div css="display: flex;align-items: center;"> <div css="display: flex;align-items: center;">
{frequency?.value === 'month' && ( {frequency === 'month' && (
<span <span
id="radio-schedule-run-on-day" id="radio-schedule-run-on-day"
css="margin-right: 10px;" css="margin-right: 10px;"
@@ -349,9 +356,9 @@ const FrequencyDetailSubform = () => {
<Trans>Day</Trans> <Trans>Day</Trans>
</span> </span>
)} )}
{frequency?.value === 'year' && ( {frequency === 'year' && (
<AnsibleSelect <AnsibleSelect
id="schedule-run-on-day-month" id={`schedule-run-on-day-month-${id}`}
css="margin-right: 10px" css="margin-right: 10px"
isDisabled={runOn.value !== 'day'} isDisabled={runOn.value !== 'day'}
data={monthOptions} data={monthOptions}
@@ -359,7 +366,7 @@ const FrequencyDetailSubform = () => {
/> />
)} )}
<TextInput <TextInput
id="schedule-run-on-day-number" id={`schedule-run-on-day-number-${id}`}
type="number" type="number"
min="1" min="1"
max="31" max="31"
@@ -380,18 +387,18 @@ const FrequencyDetailSubform = () => {
}} }}
/> />
<RunOnRadio <RunOnRadio
id="schedule-run-on-the" id={`schedule-run-on-the-${id}`}
name="runOn" name={`${prefix}.runOn`}
label={ label={
<div css="display: flex;align-items: center;"> <div css="display: flex;align-items: center;">
<span <span
id="radio-schedule-run-on-the" id={`radio-schedule-run-on-the-${id}`}
css="margin-right: 10px;" css="margin-right: 10px;"
> >
<Trans>The</Trans> <Trans>The</Trans>
</span> </span>
<AnsibleSelect <AnsibleSelect
id="schedule-run-on-the-occurrence" id={`schedule-run-on-the-occurrence-${id}`}
isDisabled={runOn.value !== 'the'} isDisabled={runOn.value !== 'the'}
data={[ data={[
{ value: 1, key: 'first', label: t`First` }, { value: 1, key: 'first', label: t`First` },
@@ -412,7 +419,7 @@ const FrequencyDetailSubform = () => {
{...runOnTheOccurrence} {...runOnTheOccurrence}
/> />
<AnsibleSelect <AnsibleSelect
id="schedule-run-on-the-day" id={`schedule-run-on-the-day-${id}`}
isDisabled={runOn.value !== 'the'} isDisabled={runOn.value !== 'the'}
data={[ data={[
{ {
@@ -464,16 +471,16 @@ const FrequencyDetailSubform = () => {
]} ]}
{...runOnTheDay} {...runOnTheDay}
/> />
{frequency?.value === 'year' && ( {frequency === 'year' && (
<> <>
<span <span
id="of-schedule-run-on-the-month" id={`of-schedule-run-on-the-month-${id}`}
css="margin-left: 10px;" css="margin-left: 10px;"
> >
<Trans>of</Trans> <Trans>of</Trans>
</span> </span>
<AnsibleSelect <AnsibleSelect
id="schedule-run-on-the-month" id={`schedule-run-on-the-month-${id}`}
isDisabled={runOn.value !== 'the'} isDisabled={runOn.value !== 'the'}
data={monthOptions} data={monthOptions}
{...runOnTheMonth} {...runOnTheMonth}
@@ -492,16 +499,16 @@ const FrequencyDetailSubform = () => {
</FormGroup> </FormGroup>
)} )}
<FormGroup <FormGroup
name="end" name={`${prefix}.end`}
fieldId="schedule-end" fieldId={`schedule-end-${id}`}
helperTextInvalid={endMeta.error} helperTextInvalid={endMeta.error}
isRequired isRequired
validated={!endMeta.touched || !endMeta.error ? 'default' : 'error'} validated={!endMeta.touched || !endMeta.error ? 'default' : 'error'}
label={t`End`} label={t`End`}
> >
<Radio <Radio
id="end-never" id={`end-never-${id}`}
name="end" name={`${prefix}.end`}
label={t`Never`} label={t`Never`}
value="never" value="never"
isChecked={end.value === 'never'} isChecked={end.value === 'never'}
@@ -509,11 +516,11 @@ const FrequencyDetailSubform = () => {
event.target.value = 'never'; event.target.value = 'never';
end.onChange(event); end.onChange(event);
}} }}
ouiaId="end-never-radio-button" ouiaId={`end-never-radio-button-${id}`}
/> />
<Radio <Radio
id="end-after" id={`end-after-${id}`}
name="end" name={`${prefix}.end`}
label={t`After number of occurrences`} label={t`After number of occurrences`}
value="after" value="after"
isChecked={end.value === 'after'} isChecked={end.value === 'after'}
@@ -521,11 +528,11 @@ const FrequencyDetailSubform = () => {
event.target.value = 'after'; event.target.value = 'after';
end.onChange(event); end.onChange(event);
}} }}
ouiaId="end-after-radio-button" ouiaId={`end-after-radio-button-${id}`}
/> />
<Radio <Radio
id="end-on-date" id={`end-on-date-${id}`}
name="end" name={`${prefix}.end`}
label={t`On date`} label={t`On date`}
value="onDate" value="onDate"
isChecked={end.value === 'onDate'} isChecked={end.value === 'onDate'}
@@ -533,25 +540,24 @@ const FrequencyDetailSubform = () => {
event.target.value = 'onDate'; event.target.value = 'onDate';
end.onChange(event); end.onChange(event);
}} }}
ouiaId="end-on-radio-button" ouiaId={`end-on-radio-button-${id}`}
/> />
</FormGroup> </FormGroup>
{end?.value === 'after' && ( {end?.value === 'after' && (
<FormField <FormField
id="schedule-occurrences" id={`schedule-occurrences-${id}`}
label={t`Occurrences`} label={t`Occurrences`}
name="occurrences" name={`${prefix}.occurrences`}
type="number" type="number"
min="1" min="1"
step="1" step="1"
validate={required(null)}
isRequired isRequired
/> />
)} )}
{end?.value === 'onDate' && ( {end?.value === 'onDate' && (
<DateTimePicker <DateTimePicker
dateFieldName={dateFieldName} dateFieldName={`${prefix}.endDate`}
timeFieldName={timeFieldName} timeFieldName={`${prefix}.endTime`}
label={t`End date/time`} 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 { DateTime } from 'luxon';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Formik, useField } from 'formik'; import { Formik } from 'formik';
import { RRule } from 'rrule'; import { RRule } from 'rrule';
import { import { Button, Form, ActionGroup } from '@patternfly/react-core';
Button, import { Config } from 'contexts/Config';
Form,
FormGroup,
Title,
ActionGroup,
// To be removed once UI completes complex schedules
Alert,
} from '@patternfly/react-core';
import { Config, useConfig } from 'contexts/Config';
import { SchedulesAPI } from 'api'; import { SchedulesAPI } from 'api';
import { dateToInputDateTime } from 'util/dates'; import { dateToInputDateTime } from 'util/dates';
import useRequest from 'hooks/useRequest'; import useRequest from 'hooks/useRequest';
import { required } from 'util/validators';
import { parseVariableField } from 'util/yaml'; import { parseVariableField } from 'util/yaml';
import Popover from '../../Popover';
import AnsibleSelect from '../../AnsibleSelect';
import ContentError from '../../ContentError'; import ContentError from '../../ContentError';
import ContentLoading from '../../ContentLoading'; import ContentLoading from '../../ContentLoading';
import FormField, { FormSubmitError } from '../../FormField'; import { FormSubmitError } from '../../FormField';
import { import { FormColumnLayout, FormFullWidthLayout } from '../../FormLayout';
FormColumnLayout,
SubFormLayout,
FormFullWidthLayout,
} from '../../FormLayout';
import FrequencyDetailSubform from './FrequencyDetailSubform';
import SchedulePromptableFields from './SchedulePromptableFields'; 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 buildRuleObj from './buildRuleObj';
import helpText from '../../../screens/Template/shared/JobTemplate.helptext';
const NUM_DAYS_PER_FREQUENCY = { const NUM_DAYS_PER_FREQUENCY = {
week: 7, week: 7,
@@ -42,173 +27,6 @@ const NUM_DAYS_PER_FREQUENCY = {
year: 365, 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({ function ScheduleForm({
hasDaysToKeepField, hasDaysToKeepField,
handleCancel, handleCancel,
@@ -415,25 +233,72 @@ function ScheduleForm({
const [currentDate, time] = dateToInputDateTime(closestQuarterHour.toISO()); const [currentDate, time] = dateToInputDateTime(closestQuarterHour.toISO());
const [tomorrowDate] = dateToInputDateTime(tomorrow.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 = { const initialValues = {
daysOfWeek: [],
description: schedule.description || '', description: schedule.description || '',
end: 'never', frequency: [],
endDate: tomorrowDate, exceptionFrequency: [],
endTime: time, frequencyOptions: initialFrequencyOptions,
frequency: 'none', exceptionOptions: initialFrequencyOptions,
interval: 1,
name: schedule.name || '', name: schedule.name || '',
occurrences: 1,
runOn: 'day',
runOnDayMonth: 1,
runOnDayNumber: 1,
runOnTheDay: 'sunday',
runOnTheMonth: 1,
runOnTheOccurrence: 1,
startDate: currentDate, startDate: currentDate,
startTime: time, startTime: time,
timezone: schedule.timezone || 'America/New_York', timezone: schedule.timezone || now.zoneName,
}; };
const submitSchedule = ( const submitSchedule = (
values, values,
@@ -465,132 +330,23 @@ function ScheduleForm({
initialValues.daysToKeep = initialDaysToKeep; initialValues.daysToKeep = initialDaysToKeep;
} }
const overriddenValues = {}; let overriddenValues = {};
if (schedule.rrule) {
if (Object.keys(schedule).length > 0) { try {
if (schedule.rrule) { overriddenValues = parseRuleObj(schedule);
if (schedule.rrule.split(/\s+/).length > 2) { } catch (error) {
if (error instanceof UnsupportedRRuleError) {
return ( return (
<Form autoComplete="off"> <UnsupportedScheduleForm
<Alert schedule={schedule}
variant="danger" handleCancel={handleCancel}
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>
); );
} }
rruleError = error;
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`);
} }
} else if (schedule.id) {
rruleError = new Error(t`Schedule is missing rrule`);
} }
if (contentError || rruleError) { if (contentError || rruleError) {
@@ -601,54 +357,83 @@ function ScheduleForm({
return <ContentLoading />; 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 ( return (
<Config> <Config>
{() => ( {() => (
<Formik <Formik
initialValues={Object.assign(initialValues, overriddenValues)} initialValues={{
...initialValues,
...overriddenValues,
frequencyOptions: {
...initialValues.frequencyOptions,
...overriddenValues.frequencyOptions,
},
exceptionOptions: {
...initialValues.exceptionOptions,
...overriddenValues.exceptionOptions,
},
}}
onSubmit={(values) => { onSubmit={(values) => {
submitSchedule(values, launchConfig, surveyConfig, credentials); submitSchedule(values, launchConfig, surveyConfig, credentials);
}} }}
validate={(values) => { validate={validate}
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;
}}
> >
{(formik) => ( {(formik) => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}> <Form autoComplete="off" onSubmit={formik.handleSubmit}>

View File

@@ -94,7 +94,7 @@ const defaultFieldsVisible = () => {
expect( expect(
wrapper.find('FormGroup[label="Local time zone"]').find('HelpIcon').length wrapper.find('FormGroup[label="Local time zone"]').find('HelpIcon').length
).toBe(1); ).toBe(1);
expect(wrapper.find('FormGroup[label="Run frequency"]').length).toBe(1); expect(wrapper.find('FrequencySelect').length).toBe(1);
}; };
const nonRRuleValuesMatch = () => { const nonRRuleValuesMatch = () => {
@@ -498,21 +498,19 @@ describe('<ScheduleForm />', () => {
expect(wrapper.find('DatePicker').prop('value')).toMatch(`${date}`); expect(wrapper.find('DatePicker').prop('value')).toMatch(`${date}`);
expect(wrapper.find('TimePicker').prop('time')).toMatch(`${time}`); expect(wrapper.find('TimePicker').prop('time')).toMatch(`${time}`);
expect(wrapper.find('select#schedule-timezone').prop('value')).toBe( expect(wrapper.find('select#schedule-timezone').prop('value')).toBe(
'America/New_York' 'UTC'
);
expect(wrapper.find('select#schedule-frequency').prop('value')).toBe(
'none'
); );
expect(
wrapper.find('FrequencySelect#schedule-frequency').prop('value')
).toEqual([]);
}); });
test('correct frequency details fields and values shown when frequency changed to minute', async () => { test('correct frequency details fields and values shown when frequency changed to minute', async () => {
const runFrequencySelect = wrapper.find( const runFrequencySelect = wrapper.find(
'FormGroup[label="Run frequency"] FormSelect' 'FrequencySelect#schedule-frequency'
); );
await act(async () => { await act(async () => {
runFrequencySelect.invoke('onChange')('minute', { runFrequencySelect.invoke('onChange')(['minute']);
target: { value: 'minute', key: 'minute', label: 'Minute' },
});
}); });
wrapper.update(); wrapper.update();
defaultFieldsVisible(); defaultFieldsVisible();
@@ -523,20 +521,30 @@ describe('<ScheduleForm />', () => {
expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0);
expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0);
expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); expect(
expect(wrapper.find('input#end-never').prop('checked')).toBe(true); wrapper
expect(wrapper.find('input#end-after').prop('checked')).toBe(false); .find('input#schedule-run-every-frequencyOptions-minute')
expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); .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 () => { test('correct frequency details fields and values shown when frequency changed to hour', async () => {
const runFrequencySelect = wrapper.find( const runFrequencySelect = wrapper.find(
'FormGroup[label="Run frequency"] FormSelect' 'FrequencySelect#schedule-frequency'
); );
await act(async () => { await act(async () => {
runFrequencySelect.invoke('onChange')('hour', { runFrequencySelect.invoke('onChange')(['hour']);
target: { value: 'hour', key: 'hour', label: 'Hour' },
});
}); });
wrapper.update(); wrapper.update();
defaultFieldsVisible(); defaultFieldsVisible();
@@ -547,20 +555,28 @@ describe('<ScheduleForm />', () => {
expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0);
expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0);
expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); expect(
expect(wrapper.find('input#end-never').prop('checked')).toBe(true); wrapper
expect(wrapper.find('input#end-after').prop('checked')).toBe(false); .find('input#schedule-run-every-frequencyOptions-hour')
expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); .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 () => { test('correct frequency details fields and values shown when frequency changed to day', async () => {
const runFrequencySelect = wrapper.find( const runFrequencySelect = wrapper.find(
'FormGroup[label="Run frequency"] FormSelect' 'FrequencySelect#schedule-frequency'
); );
await act(async () => { await act(async () => {
runFrequencySelect.invoke('onChange')('day', { runFrequencySelect.invoke('onChange')(['day']);
target: { value: 'day', key: 'day', label: 'Day' },
});
}); });
wrapper.update(); wrapper.update();
defaultFieldsVisible(); defaultFieldsVisible();
@@ -571,20 +587,28 @@ describe('<ScheduleForm />', () => {
expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0);
expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0);
expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); expect(
expect(wrapper.find('input#end-never').prop('checked')).toBe(true); wrapper
expect(wrapper.find('input#end-after').prop('checked')).toBe(false); .find('input#schedule-run-every-frequencyOptions-day')
expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); .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 () => { test('correct frequency details fields and values shown when frequency changed to week', async () => {
const runFrequencySelect = wrapper.find( const runFrequencySelect = wrapper.find(
'FormGroup[label="Run frequency"] FormSelect' 'FrequencySelect#schedule-frequency'
); );
await act(async () => { await act(async () => {
runFrequencySelect.invoke('onChange')('week', { runFrequencySelect.invoke('onChange')(['week']);
target: { value: 'week', key: 'week', label: 'Week' },
});
}); });
wrapper.update(); wrapper.update();
defaultFieldsVisible(); defaultFieldsVisible();
@@ -595,20 +619,28 @@ describe('<ScheduleForm />', () => {
expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0);
expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0);
expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); expect(
expect(wrapper.find('input#end-never').prop('checked')).toBe(true); wrapper
expect(wrapper.find('input#end-after').prop('checked')).toBe(false); .find('input#schedule-run-every-frequencyOptions-week')
expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); .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 () => { test('correct frequency details fields and values shown when frequency changed to month', async () => {
const runFrequencySelect = wrapper.find( const runFrequencySelect = wrapper.find(
'FormGroup[label="Run frequency"] FormSelect' 'FrequencySelect#schedule-frequency'
); );
await act(async () => { await act(async () => {
runFrequencySelect.invoke('onChange')('month', { runFrequencySelect.invoke('onChange')(['month']);
target: { value: 'month', key: 'month', label: 'Month' },
});
}); });
wrapper.update(); wrapper.update();
defaultFieldsVisible(); defaultFieldsVisible();
@@ -619,31 +651,45 @@ describe('<ScheduleForm />', () => {
expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0);
expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); expect(wrapper.find('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( expect(
wrapper.find('input#schedule-run-on-day-number').prop('value') wrapper
.find('input#schedule-run-every-frequencyOptions-month')
.prop('value')
).toBe(1); ).toBe(1);
expect(wrapper.find('input#schedule-run-on-the').prop('checked')).toBe( expect(
false 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-day-month').length).toBe(0);
expect(wrapper.find('select#schedule-run-on-the-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 () => { test('correct frequency details fields and values shown when frequency changed to year', async () => {
const runFrequencySelect = wrapper.find( const runFrequencySelect = wrapper.find(
'FormGroup[label="Run frequency"] FormSelect' 'FrequencySelect#schedule-frequency'
); );
await act(async () => { await act(async () => {
runFrequencySelect.invoke('onChange')('year', { runFrequencySelect.invoke('onChange')(['year']);
target: { value: 'year', key: 'year', label: 'Year' },
});
}); });
wrapper.update(); wrapper.update();
defaultFieldsVisible(); defaultFieldsVisible();
@@ -654,73 +700,125 @@ describe('<ScheduleForm />', () => {
expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0); expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0);
expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0); expect(wrapper.find('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( 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); ).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 () => { 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 () => { await act(async () => {
wrapper wrapper
.find('FormGroup[label="Run frequency"] FormSelect') .find('Radio#end-after-frequencyOptions-minute')
.invoke('onChange')('minute', { .invoke('onChange')('after', {
target: { value: 'minute', key: 'minute', label: 'Minute' }, target: { name: 'frequencyOptions.minute.end' },
}); });
}); });
wrapper.update(); wrapper.update();
await act(async () => { expect(
wrapper.find('Radio#end-after').invoke('onChange')('after', { wrapper.find('input#end-never-frequencyOptions-minute').prop('checked')
target: { name: 'end' }, ).toBe(false);
}); expect(
}); wrapper.find('input#end-after-frequencyOptions-minute').prop('checked')
wrapper.update(); ).toBe(true);
expect(wrapper.find('input#end-never').prop('checked')).toBe(false); expect(
expect(wrapper.find('input#end-after').prop('checked')).toBe(true); wrapper
expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); .find('input#end-on-date-frequencyOptions-minute')
.prop('checked')
).toBe(false);
expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(1); 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 () => { await act(async () => {
wrapper.find('Radio#end-never').invoke('onChange')('never', { wrapper
target: { name: 'end' }, .find('Radio#end-never-frequencyOptions-minute')
.invoke('onChange')('never', {
target: { name: 'frequencyOptions.minute.end' },
}); });
}); });
wrapper.update(); wrapper.update();
expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0);
}); });
test('error shown when end date/time comes before start date/time', async () => { test('error shown when end date/time comes before start date/time', async () => {
await act(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 wrapper
.find('FormGroup[label="Run frequency"] FormSelect') .find('input#end-on-date-frequencyOptions-minute')
.invoke('onChange')('minute', { .prop('checked')
target: { value: 'minute', key: 'minute', label: 'Minute' }, ).toBe(false);
});
});
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);
await act(async () => { await act(async () => {
wrapper.find('Radio#end-on-date').invoke('onChange')('onDate', { wrapper
target: { name: 'end' }, .find('Radio#end-on-date-frequencyOptions-minute')
.invoke('onChange')('onDate', {
target: { name: 'frequencyOptions.minute.end' },
}); });
}); });
wrapper.update(); wrapper.update();
expect(wrapper.find('input#end-never').prop('checked')).toBe(false); expect(
expect(wrapper.find('input#end-after').prop('checked')).toBe(false); wrapper.find('input#end-never-frequencyOptions-minute').prop('checked')
expect(wrapper.find('input#end-on-date').prop('checked')).toBe(true); ).toBe(false);
expect(wrapper.find('#schedule-end-datetime-helper').length).toBe(0); 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 () => { await act(async () => {
wrapper.find('DatePicker[aria-label="End date"]').prop('onChange')( wrapper.find('DatePicker[aria-label="End date"]').prop('onChange')(
'2020-03-14', '2020-03-14',
@@ -739,26 +837,29 @@ describe('<ScheduleForm />', () => {
}); });
test('error shown when on day number is not between 1 and 31', async () => { test('error shown when on day number is not between 1 and 31', async () => {
act(() => { await act(async () => {
wrapper.find('select[id="schedule-frequency"]').invoke('onChange')( wrapper.find('FrequencySelect#schedule-frequency').invoke('onChange')([
{ 'month',
currentTarget: { value: 'month', type: 'change' }, ]);
target: { name: 'frequency', value: 'month' },
},
'month'
);
}); });
wrapper.update(); wrapper.update();
act(() => { act(() => {
wrapper.find('input#schedule-run-on-day-number').simulate('change', { wrapper
target: { value: 32, name: 'runOnDayNumber' }, .find('input#schedule-run-on-day-number-frequencyOptions-month')
}); .simulate('change', {
target: {
value: 32,
name: 'frequencyOptions.month.runOnDayNumber',
},
});
}); });
wrapper.update(); wrapper.update();
expect( 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); ).toBe(32);
await act(async () => { await act(async () => {
@@ -766,9 +867,9 @@ describe('<ScheduleForm />', () => {
}); });
wrapper.update(); wrapper.update();
expect(wrapper.find('#schedule-run-on-helper').text()).toBe( expect(
'Please select a day number between 1 and 31.' 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); expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0);
nonRRuleValuesMatch(); nonRRuleValuesMatch();
expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( expect(
'none' wrapper.find('FrequencySelect#schedule-frequency').prop('value')
); ).toEqual([]);
}); });
test('initially renders expected fields and values with existing schedule that runs every 10 minutes', async () => { 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); expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0);
nonRRuleValuesMatch(); nonRRuleValuesMatch();
expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( expect(
'minute' wrapper.find('FrequencySelect#schedule-frequency').prop('value')
); ).toEqual(['minute']);
expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(10); expect(
expect(wrapper.find('input#end-never').prop('checked')).toBe(true); wrapper
expect(wrapper.find('input#end-after').prop('checked')).toBe(false); .find('input#schedule-run-every-frequencyOptions-minute')
expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); .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 () => { 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); expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0);
nonRRuleValuesMatch(); nonRRuleValuesMatch();
expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( expect(
'hour' wrapper.find('FrequencySelect#schedule-frequency').prop('value')
); ).toEqual(['hour']);
expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); expect(
expect(wrapper.find('input#end-never').prop('checked')).toBe(false); wrapper
expect(wrapper.find('input#end-after').prop('checked')).toBe(true); .find('input#schedule-run-every-frequencyOptions-hour')
expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); .prop('value')
expect(wrapper.find('input#schedule-occurrences').prop('value')).toBe(10); ).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 () => { 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); expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0);
nonRRuleValuesMatch(); nonRRuleValuesMatch();
expect(wrapper.find('select#schedule-frequency').prop('value')).toBe( expect(
'day' wrapper.find('FrequencySelect#schedule-frequency').prop('value')
); ).toEqual(['day']);
expect(wrapper.find('input#end-never').prop('checked')).toBe(true); expect(
expect(wrapper.find('input#end-after').prop('checked')).toBe(false); wrapper.find('input#end-never-frequencyOptions-day').prop('checked')
expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false); ).toBe(true);
expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1); 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 () => { 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); expect(wrapper.find('FormGroup[label="Run on"]').length).toBe(0);
nonRRuleValuesMatch(); 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( 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); ).toBe(false);
expect( expect(
wrapper.find('input#schedule-days-of-week-mon').prop('checked') wrapper.find('input#end-after-frequencyOptions-week').prop('checked')
).toBe(true);
expect(
wrapper.find('input#schedule-days-of-week-tue').prop('checked')
).toBe(false); ).toBe(false);
expect( expect(
wrapper.find('input#schedule-days-of-week-wed').prop('checked') wrapper.find('input#end-on-date-frequencyOptions-week').prop('checked')
).toBe(true); ).toBe(true);
expect( 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); ).toBe(false);
expect( 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); ).toBe(true);
expect( 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); ).toBe(false);
expect( expect(
wrapper.find('DatePicker[aria-label="End date"]').prop('value') wrapper.find('DatePicker[aria-label="End date"]').prop('value')
).toBe('2021-01-01'); ).toBe('2021-01-01');
expect( expect(
wrapper.find('TimePicker[aria-label="End time"]').prop('value') 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 () => { 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); expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0);
nonRRuleValuesMatch(); 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( 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); ).toBe(-1);
expect(wrapper.find('select#schedule-run-on-the-day').prop('value')).toBe( expect(
'weekday' 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 () => { 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); expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0);
nonRRuleValuesMatch(); 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( 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); ).toBe(5);
expect( 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); ).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({ resetForm({
values: { values: {
...initialValues, ...initialValues,
daysOfWeek: values.daysOfWeek,
description: values.description, description: values.description,
end: values.end,
endDateTime: values.endDateTime,
frequency: values.frequency, frequency: values.frequency,
interval: values.interval,
name: values.name, 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, startDateTime: values.startDateTime,
timezone: values.timezone, 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 { DateTime } from 'luxon';
import { getRRuleDayConstants } from 'util/dates'; import { getRRuleDayConstants } from 'util/dates';
window.RRule = RRule;
window.DateTime = DateTime;
const parseTime = (time) => [ const parseTime = (time) => [
DateTime.fromFormat(time, 'h:mm a').hour, DateTime.fromFormat(time, 'h:mm a').hour,
DateTime.fromFormat(time, 'h:mm a').minute, DateTime.fromFormat(time, 'h:mm a').minute,
]; ];
export default function buildRuleObj(values) { export function buildDtStartObj(values) {
// Dates are formatted like "YYYY-MM-DD" // Dates are formatted like "YYYY-MM-DD"
const [startYear, startMonth, startDay] = values.startDate.split('-'); const [startYear, startMonth, startDay] = values.startDate.split('-');
// Times are formatted like "HH:MM:SS" or "HH:MM" if no seconds // Times are formatted like "HH:MM:SS" or "HH:MM" if no seconds
// have been specified // have been specified
const [startHour, startMinute] = parseTime(values.startTime); 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 = { const ruleObj = {
interval: values.interval, interval: values.interval,
dtstart: new Date(
Date.UTC(
startYear,
parseInt(startMonth, 10) - 1,
startDay,
startHour,
startMinute
)
),
tzid: values.timezone,
}; };
switch (values.frequency) { switch (values.frequency) {
@@ -79,22 +91,20 @@ export default function buildRuleObj(values) {
ruleObj.count = values.occurrences; ruleObj.count = values.occurrences;
break; break;
case 'onDate': { case 'onDate': {
const [endYear, endMonth, endDay] = values.endDate.split('-');
const [endHour, endMinute] = parseTime(values.endTime); const [endHour, endMinute] = parseTime(values.endTime);
ruleObj.until = new Date( const localEndDate = DateTime.fromISO(`${values.endDate}T000000`, {
Date.UTC( zone: values.timezone,
endYear, });
parseInt(endMonth, 10) - 1, const localEndTime = localEndDate.set({
endDay, hour: endHour,
endHour, minute: endMinute,
endMinute second: 0,
) });
); ruleObj.until = localEndTime.toJSDate();
break; break;
} }
default: 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; 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;
};
}