Implement schedule add form on JT/WFJT/Proj

This commit is contained in:
mabashian 2020-03-25 11:59:57 -04:00
parent 827adbce76
commit d9b613ccb3
21 changed files with 1760 additions and 52 deletions

View File

@ -1,5 +1,9 @@
const SchedulesMixin = parent =>
class extends parent {
createSchedule(id, data) {
return this.http.post(`${this.baseUrl}${id}/schedules/`, data);
}
readSchedules(id, params) {
return this.http.get(`${this.baseUrl}${id}/schedules/`, { params });
}

View File

@ -13,6 +13,10 @@ class Schedules extends Base {
readCredentials(resourceId, params) {
return this.http.get(`${this.baseUrl}${resourceId}/credentials/`, params);
}
readZoneInfo() {
return this.http.get(`${this.baseUrl}zoneinfo/`);
}
}
export default Schedules;

View File

@ -37,7 +37,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
}
isOpen={true}
onClose={[Function]}
title="Remove {0} Access"
title="Remove Team Access"
variant="danger"
>
<Modal
@ -71,7 +71,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
>
<div
aria-describedby="pf-modal-0"
aria-label="Remove {0} Access"
aria-label="Remove Team Access"
aria-modal="true"
class="pf-c-modal-box pf-m-sm"
role="dialog"
@ -120,7 +120,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
<h1
class="pf-c-title pf-m-2xl"
>
Remove {0} Access
Remove Team Access
</h1>
</div>
</div>
@ -128,7 +128,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
class="pf-c-modal-box__body"
id="pf-modal-0"
>
Are you sure you want to remove {0} access from {1}? Doing so affects all members of the team.
Are you sure you want to remove Member access from The Team? Doing so affects all members of the team.
<br />
<br />
If you only want to remove access for this particular user, please remove them from the team.
@ -166,7 +166,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
<Title
size="2xl"
>
Remove {0} Access
Remove Team Access
</Title>
</AlertModal__Header>
}
@ -177,7 +177,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
isSmall={true}
onClose={[Function]}
showClose={true}
title="Remove {0} Access"
title="Remove Team Access"
>
<Portal
containerInfo={
@ -190,7 +190,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
>
<div
aria-describedby="pf-modal-0"
aria-label="Remove {0} Access"
aria-label="Remove Team Access"
aria-modal="true"
class="pf-c-modal-box pf-m-sm"
role="dialog"
@ -239,7 +239,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
<h1
class="pf-c-title pf-m-2xl"
>
Remove {0} Access
Remove Team Access
</h1>
</div>
</div>
@ -247,7 +247,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
class="pf-c-modal-box__body"
id="pf-modal-0"
>
Are you sure you want to remove {0} access from {1}? Doing so affects all members of the team.
Are you sure you want to remove Member access from The Team? Doing so affects all members of the team.
<br />
<br />
If you only want to remove access for this particular user, please remove them from the team.
@ -303,7 +303,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
<Title
size="2xl"
>
Remove {0} Access
Remove Team Access
</Title>
</AlertModal__Header>
}
@ -315,7 +315,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
isSmall={true}
onClose={[Function]}
showClose={true}
title="Remove {0} Access"
title="Remove Team Access"
>
<Backdrop>
<div
@ -342,11 +342,11 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
isLarge={false}
isSmall={true}
style={Object {}}
title="Remove {0} Access"
title="Remove Team Access"
>
<div
aria-describedby="pf-modal-0"
aria-label="Remove {0} Access"
aria-label="Remove Team Access"
aria-modal="true"
className="pf-c-modal-box pf-m-sm"
role="dialog"
@ -528,7 +528,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
<h1
className="pf-c-title pf-m-2xl"
>
Remove {0} Access
Remove Team Access
</h1>
</Title>
</div>
@ -542,7 +542,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
className="pf-c-modal-box__body"
id="pf-modal-0"
>
Are you sure you want to remove {0} access from {1}? Doing so affects all members of the team.
Are you sure you want to remove Member access from The Team? Doing so affects all members of the team.
<br />
<br />
If you only want to remove access for this particular user, please remove them from the team.

View File

@ -0,0 +1,168 @@
import React, { useState } from 'react';
import { func } from 'prop-types';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { useHistory, useLocation } from 'react-router-dom';
import { RRule } from 'rrule';
import { Card } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import { getWeekNumber } from '@util/dates';
import ScheduleForm from '../shared/ScheduleForm';
const days = {
0: 'SU',
1: 'MO',
2: 'TU',
3: 'WE',
4: 'TH',
5: 'FR',
6: 'SA',
};
function ScheduleAdd({ i18n, createSchedule }) {
const [formSubmitError, setFormSubmitError] = useState(null);
const history = useHistory();
const location = useLocation();
const { pathname } = location;
const pathRoot = pathname.substr(0, pathname.indexOf('schedules'));
const handleSubmit = async values => {
try {
const [startDate, startTime] = values.startDateTime.split('T');
// Dates are formatted like "YYYY-MM-DD"
const [startYear, startMonth, startDay] = startDate.split('-');
// Times are formatted like "HH:MM:SS" or "HH:MM" if no seconds
// have been specified
const [startHour = 0, startMinute = 0, startSecond = 0] = startTime.split(
':'
);
const ruleObj = {
interval: values.interval,
dtstart: new Date(
Date.UTC(
startYear,
parseInt(startMonth, 10) - 1,
startDay,
startHour,
startMinute,
startSecond
)
),
tzid: values.timezone,
};
switch (values.frequency) {
case 'none':
ruleObj.count = 1;
ruleObj.freq = RRule.MINUTELY;
break;
case 'minute':
ruleObj.freq = RRule.MINUTELY;
break;
case 'hour':
ruleObj.freq = RRule.HOURLY;
break;
case 'day':
ruleObj.freq = RRule.DAILY;
break;
case 'week':
ruleObj.freq = RRule.WEEKLY;
ruleObj.byweekday = values.daysOfWeek.map(day => RRule[day]);
break;
case 'month':
ruleObj.freq = RRule.MONTHLY;
if (values.runOn === 'number') {
ruleObj.bymonthday = startDay;
} else if (values.runOn === 'day') {
ruleObj.byweekday =
RRule[days[new Date(values.startDateTime).getDay()]];
ruleObj.bysetpos = getWeekNumber(values.startDateTime);
} else if (values.runOn === 'lastDay') {
ruleObj.byweekday =
RRule[days[new Date(values.startDateTime).getDay()]];
ruleObj.bysetpos = -1;
}
break;
case 'year':
ruleObj.freq = RRule.YEARLY;
ruleObj.bymonth = new Date(values.startDateTime).getMonth() + 1;
if (values.runOn === 'number') {
ruleObj.bymonthday = startDay;
} else if (values.runOn === 'day') {
ruleObj.byweekday =
RRule[days[new Date(values.startDateTime).getDay()]];
ruleObj.bysetpos = getWeekNumber(values.startDateTime);
} else if (values.runOn === 'lastDay') {
ruleObj.byweekday =
RRule[days[new Date(values.startDateTime).getDay()]];
ruleObj.bysetpos = -1;
}
break;
default:
throw new Error(i18n._(t`Frequency did not match an expected value`));
}
switch (values.end) {
case 'never':
break;
case 'after':
ruleObj.count = values.occurrences;
break;
case 'onDate': {
const [endDate, endTime] = values.endDateTime.split('T');
const [endYear, endMonth, endDay] = endDate.split('-');
const [endHour = 0, endMinute = 0, endSecond = 0] = endTime.split(
':'
);
ruleObj.until = new Date(
Date.UTC(
endYear,
parseInt(endMonth, 10) - 1,
endDay,
endHour,
endMinute,
endSecond
)
);
break;
}
default:
throw new Error(i18n._(t`End did not match an expected value`));
}
const rule = new RRule(ruleObj);
const {
data: { id: scheduleId },
} = await createSchedule({
name: values.name,
description: values.description,
rrule: rule.toString().replace(/\n/g, ' '),
});
history.push(`${pathRoot}schedules/${scheduleId}`);
} catch (err) {
setFormSubmitError(err);
}
};
return (
<Card>
<CardBody>
<ScheduleForm
handleCancel={() => history.push(`${pathRoot}schedules`)}
handleSubmit={handleSubmit}
submitError={formSubmitError}
/>
</CardBody>
</Card>
);
}
ScheduleAdd.propTypes = {
createSchedule: func.isRequired,
};
ScheduleAdd.defaultProps = {};
export default withI18n()(ScheduleAdd);

View File

@ -0,0 +1,227 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { SchedulesAPI } from '@api';
import ScheduleAdd from './ScheduleAdd';
jest.mock('@api/models/Schedules');
SchedulesAPI.readZoneInfo.mockResolvedValue({
data: [
{
name: 'America/New_York',
},
],
});
let wrapper;
const createSchedule = jest.fn().mockImplementation(() => {
return {
data: {
id: 1,
},
};
});
describe('<ScheduleAdd />', () => {
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<ScheduleAdd createSchedule={createSchedule} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
jest.clearAllMocks();
});
test('Successfully creates a schedule with repeat frequency: None (run once)', async () => {
await act(async () => {
wrapper.find('ScheduleForm').invoke('handleSubmit')({
description: 'test description',
end: 'never',
frequency: 'none',
interval: 1,
name: 'Run once schedule',
startDateTime: '2020-03-25T10:00:00',
timezone: 'America/New_York',
});
});
expect(createSchedule).toHaveBeenCalledWith({
description: 'test description',
name: 'Run once schedule',
rrule:
'DTSTART;TZID=America/New_York:20200325T100000 RRULE:INTERVAL=1;COUNT=1;FREQ=MINUTELY',
});
});
test('Successfully creates a schedule with 10 minute repeat frequency after 10 occurrences', async () => {
await act(async () => {
wrapper.find('ScheduleForm').invoke('handleSubmit')({
description: 'test description',
end: 'after',
frequency: 'minute',
interval: 10,
name: 'Run every 10 minutes 10 times',
occurrences: 10,
runOn: 'number',
startDateTime: '2020-03-25T10:30:00',
timezone: 'America/New_York',
});
});
expect(createSchedule).toHaveBeenCalledWith({
description: 'test description',
name: 'Run every 10 minutes 10 times',
rrule:
'DTSTART;TZID=America/New_York:20200325T103000 RRULE:INTERVAL=10;FREQ=MINUTELY;COUNT=10',
});
});
test('Successfully creates a schedule with hourly repeat frequency ending on a specific date/time', async () => {
await act(async () => {
wrapper.find('ScheduleForm').invoke('handleSubmit')({
description: 'test description',
end: 'onDate',
endDateTime: '2020-03-26T10:45:00',
frequency: 'hour',
interval: 1,
name: 'Run every hour until date',
runOn: 'number',
startDateTime: '2020-03-25T10:45:00',
timezone: 'America/New_York',
});
});
expect(createSchedule).toHaveBeenCalledWith({
description: 'test description',
name: 'Run every hour until date',
rrule:
'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=HOURLY;UNTIL=20200326T104500',
});
});
test('Successfully creates a schedule with daily repeat frequency', async () => {
await act(async () => {
wrapper.find('ScheduleForm').invoke('handleSubmit')({
description: 'test description',
end: 'never',
frequency: 'day',
interval: 1,
name: 'Run daily',
runOn: 'number',
startDateTime: '2020-03-25T10:45:00',
timezone: 'America/New_York',
});
});
expect(createSchedule).toHaveBeenCalledWith({
description: 'test description',
name: 'Run daily',
rrule:
'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=DAILY',
});
});
test('Successfully creates a schedule with weekly repeat frequency on mon/wed/fri', async () => {
await act(async () => {
wrapper.find('ScheduleForm').invoke('handleSubmit')({
daysOfWeek: ['MO', 'WE', 'FR'],
description: 'test description',
end: 'never',
frequency: 'week',
interval: 1,
name: 'Run weekly on mon/wed/fri',
occurrences: 1,
runOn: 'number',
startDateTime: '2020-03-25T10:45:00',
timezone: 'America/New_York',
});
});
expect(createSchedule).toHaveBeenCalledWith({
description: 'test description',
name: 'Run weekly on mon/wed/fri',
rrule:
'DTSTART;TZID=America/New_York:20200325T104500 RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=MO,WE,FR',
});
});
test('Successfully creates a schedule with monthly repeat frequency on the first day of the month', async () => {
await act(async () => {
wrapper.find('ScheduleForm').invoke('handleSubmit')({
description: 'test description',
end: 'never',
frequency: 'month',
interval: 1,
name: 'Run on the first day of the month',
occurrences: 1,
runOn: 'number',
startDateTime: '2020-04-01T10:45',
timezone: 'America/New_York',
});
});
expect(createSchedule).toHaveBeenCalledWith({
description: 'test description',
name: 'Run on the first day of the month',
rrule:
'DTSTART;TZID=America/New_York:20200401T104500 RRULE:INTERVAL=1;FREQ=MONTHLY;BYMONTHDAY=01',
});
});
test('Successfully creates a schedule with monthly repeat frequency on the last tuesday of the month', async () => {
await act(async () => {
wrapper.find('ScheduleForm').invoke('handleSubmit')({
description: 'test description',
end: 'never',
endDateTime: '2020-03-26T11:00:00',
frequency: 'month',
interval: 1,
name: 'Run monthly on the last Tuesday',
occurrences: 1,
runOn: 'lastDay',
startDateTime: '2020-03-31T11:00',
timezone: 'America/New_York',
});
});
expect(createSchedule).toHaveBeenCalledWith({
description: 'test description',
name: 'Run monthly on the last Tuesday',
rrule:
'DTSTART;TZID=America/New_York:20200331T110000 RRULE:INTERVAL=1;FREQ=MONTHLY;BYDAY=TU;BYSETPOS=-1',
});
});
test('Successfully creates a schedule with yearly repeat frequency on the first day of March', async () => {
await act(async () => {
wrapper.find('ScheduleForm').invoke('handleSubmit')({
description: 'test description',
end: 'never',
frequency: 'year',
interval: 1,
name: 'Yearly on the first day of March',
occurrences: 1,
runOn: 'number',
startDateTime: '2020-03-01T00:00',
timezone: 'America/New_York',
});
});
expect(createSchedule).toHaveBeenCalledWith({
description: 'test description',
name: 'Yearly on the first day of March',
rrule:
'DTSTART;TZID=America/New_York:20200301T000000 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=01',
});
});
test('Successfully creates a schedule with yearly repeat frequency on the second Friday in April', async () => {
await act(async () => {
wrapper.find('ScheduleForm').invoke('handleSubmit')({
description: 'test description',
end: 'never',
frequency: 'year',
interval: 1,
name: 'Yearly on the second Friday in April',
occurrences: 1,
runOn: 'day',
startDateTime: '2020-04-10T11:15',
timezone: 'America/New_York',
});
});
expect(createSchedule).toHaveBeenCalledWith({
description: 'test description',
name: 'Yearly on the second Friday in April',
rrule:
'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYMONTH=4;BYDAY=FR;BYSETPOS=2',
});
});
});

View File

@ -0,0 +1 @@
export { default } from './ScheduleAdd';

View File

@ -1,18 +1,23 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { Switch, Route, useRouteMatch } from 'react-router-dom';
import { Schedule, ScheduleList } from '@components/Schedule';
import { Schedule, ScheduleAdd, ScheduleList } from '@components/Schedule';
function Schedules({
createSchedule,
loadScheduleOptions,
loadSchedules,
setBreadcrumb,
unifiedJobTemplate,
loadSchedules,
loadScheduleOptions,
}) {
const match = useRouteMatch();
return (
<Switch>
<Route
path={`${match.path}/add`}
render={() => <ScheduleAdd createSchedule={createSchedule} />}
/>
<Route
key="details"
path={`${match.path}/:scheduleId`}

View File

@ -4,3 +4,4 @@ export { default as ScheduleList } from './ScheduleList';
export { default as ScheduleOccurrences } from './ScheduleOccurrences';
export { default as ScheduleToggle } from './ScheduleToggle';
export { default as ScheduleDetail } from './ScheduleDetail';
export { default as ScheduleAdd } from './ScheduleAdd';

View File

@ -0,0 +1,445 @@
import React, { useEffect } from 'react';
import styled from 'styled-components';
import { useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Checkbox as _Checkbox,
FormGroup,
Radio,
TextInput,
} from '@patternfly/react-core';
import FormField from '@components/FormField';
import {
getDaysInMonth,
getDayString,
getMonthString,
getWeekString,
getWeekNumber,
} from '@util/dates';
import { required } from '@util/validators';
const RunEveryLabel = styled.p`
display: flex;
align-items: center;
`;
const Checkbox = styled(_Checkbox)`
:not(:last-of-type) {
margin-right: 10px;
}
`;
export function requiredPositiveInteger(i18n) {
return value => {
if (typeof value === 'number') {
if (!Number.isInteger(value)) {
return i18n._(t`This field must an integer`);
}
if (value < 1) {
return i18n._(t`This field must be greater than 0`);
}
}
if (!value) {
return i18n._(t`Select a value for this field`);
}
return undefined;
};
}
const FrequencyDetailSubform = ({ i18n }) => {
const [startDateTime] = useField({
name: 'startDateTime',
});
const [daysOfWeek, daysOfWeekMeta, daysOfWeekHelpers] = useField({
name: 'daysOfWeek',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const [end, endMeta] = useField({
name: 'end',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const [interval, intervalMeta] = useField({
name: 'interval',
validate: requiredPositiveInteger(i18n),
});
const [runOn, runOnMeta, runOnHelpers] = useField({
name: 'runOn',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const [endDateTime, endDateTimeMeta] = useField({
name: 'endDateTime',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const [frequency] = useField({
name: 'frequency',
});
useField({
name: 'occurrences',
validate: requiredPositiveInteger(i18n),
});
useEffect(() => {
// The Last day option disappears if the start date isn't in the
// last week of the month. If that value was selected when this
// happens then we'll clear out the selection and force the user
// to choose between the remaining two.
if (
(frequency.value === 'month' || frequency.value === 'year') &&
runOn.value === 'lastDay' &&
getDaysInMonth(startDateTime.value) - 7 >=
new Date(startDateTime.value).getDate()
) {
runOnHelpers.setValue('');
}
}, [startDateTime.value, frequency.value, runOn.value, runOnHelpers]);
const updateDaysOfWeek = (day, checked) => {
const newDaysOfWeek = [...daysOfWeek.value];
if (checked) {
newDaysOfWeek.push(day);
daysOfWeekHelpers.setValue(newDaysOfWeek);
} else {
daysOfWeekHelpers.setValue(
newDaysOfWeek.filter(selectedDay => selectedDay !== day)
);
}
};
const getRunEveryLabel = () => {
switch (frequency.value) {
case 'minute':
return i18n.plural({
value: interval.value,
one: 'minute',
other: 'minutes',
});
case 'hour':
return i18n.plural({
value: interval.value,
one: 'hour',
other: 'hours',
});
case 'day':
return i18n.plural({
value: interval.value,
one: 'day',
other: 'days',
});
case 'week':
return i18n.plural({
value: interval.value,
one: 'week',
other: 'weeks',
});
case 'month':
return i18n.plural({
value: interval.value,
one: 'month',
other: 'months',
});
case 'year':
return i18n.plural({
value: interval.value,
one: 'year',
other: 'years',
});
default:
throw new Error(i18n._(t`Frequency did not match an expected value`));
}
};
const generateRunOnNumberLabel = () => {
switch (frequency.value) {
case 'month':
return i18n._(
t`Day ${startDateTime.value.split('T')[0].split('-')[2]}`
);
case 'year': {
const monthString = getMonthString(
new Date(startDateTime.value).getMonth(),
i18n
);
return `${monthString} ${new Date(startDateTime.value).getDate()}`;
}
default:
throw new Error(i18n._(t`Frequency did not match an expected value`));
}
};
const generateRunOnDayLabel = () => {
const dayString = getDayString(
new Date(startDateTime.value).getDay(),
i18n
);
const weekNumber = getWeekNumber(startDateTime.value);
const weekString = getWeekString(weekNumber, i18n);
switch (frequency.value) {
case 'month':
return i18n._(t`The ${weekString} ${dayString}`);
case 'year': {
const monthString = getMonthString(
new Date(startDateTime.value).getMonth(),
i18n
);
return i18n._(t`The ${weekString} ${dayString} in ${monthString}`);
}
default:
throw new Error(i18n._(t`Frequency did not match an expected value`));
}
};
const generateRunOnLastDayLabel = () => {
const dayString = getDayString(
new Date(startDateTime.value).getDay(),
i18n
);
switch (frequency.value) {
case 'month':
return i18n._(t`The last ${dayString}`);
case 'year': {
const monthString = getMonthString(
new Date(startDateTime.value).getMonth(),
i18n
);
return i18n._(t`The last ${dayString} in ${monthString}`);
}
default:
throw new Error(i18n._(t`Frequency did not match an expected value`));
}
};
/* eslint-disable no-restricted-globals */
return (
<>
<FormGroup
name="interval"
fieldId="schedule-run-every"
helperTextInvalid={intervalMeta.error}
isRequired
isValid={!intervalMeta.touched || !intervalMeta.error}
label={i18n._(t`Run every`)}
>
<div css="display: flex">
<TextInput
css="margin-right: 10px;"
id="schedule-run-every"
type="number"
min="1"
step="1"
{...interval}
onChange={(value, event) => {
interval.onChange(event);
}}
/>
<RunEveryLabel>{getRunEveryLabel()}</RunEveryLabel>
</div>
</FormGroup>
{frequency?.value === 'week' && (
<FormGroup
name="daysOfWeek"
fieldId="schedule-days-of-week"
helperTextInvalid={daysOfWeekMeta.error}
isRequired
isValid={!daysOfWeekMeta.touched || !daysOfWeekMeta.error}
label={i18n._(t`On days`)}
>
<div css="display: flex">
<Checkbox
label={i18n._(t`Sun`)}
isChecked={daysOfWeek.value.includes('SU')}
onChange={checked => {
updateDaysOfWeek('SU', checked);
}}
aria-label={i18n._(t`Sunday`)}
id="days-of-week-sun"
name="daysOfWeek"
/>
<Checkbox
label={i18n._(t`Mon`)}
isChecked={daysOfWeek.value.includes('MO')}
onChange={checked => {
updateDaysOfWeek('MO', checked);
}}
aria-label={i18n._(t`Monday`)}
id="days-of-week-mon"
name="daysOfWeek"
/>
<Checkbox
label={i18n._(t`Tue`)}
isChecked={daysOfWeek.value.includes('TU')}
onChange={checked => {
updateDaysOfWeek('TU', checked);
}}
aria-label={i18n._(t`Tuesday`)}
id="days-of-week-tue"
name="daysOfWeek"
/>
<Checkbox
label={i18n._(t`Wed`)}
isChecked={daysOfWeek.value.includes('WE')}
onChange={checked => {
updateDaysOfWeek('WE', checked);
}}
aria-label={i18n._(t`Wednesday`)}
id="days-of-week-wed"
name="daysOfWeek"
/>
<Checkbox
label={i18n._(t`Thu`)}
isChecked={daysOfWeek.value.includes('TH')}
onChange={checked => {
updateDaysOfWeek('TH', checked);
}}
aria-label={i18n._(t`Thursday`)}
id="days-of-week-thu"
name="daysOfWeek"
/>
<Checkbox
label={i18n._(t`Fri`)}
isChecked={daysOfWeek.value.includes('FR')}
onChange={checked => {
updateDaysOfWeek('FR', checked);
}}
aria-label={i18n._(t`Friday`)}
id="days-of-week-fri"
name="daysOfWeek"
/>
<Checkbox
label={i18n._(t`Sat`)}
isChecked={daysOfWeek.value.includes('SA')}
onChange={checked => {
updateDaysOfWeek('SA', checked);
}}
aria-label={i18n._(t`Saturday`)}
id="days-of-week-sat"
name="daysOfWeek"
/>
</div>
</FormGroup>
)}
{(frequency?.value === 'month' || frequency?.value === 'year') &&
!isNaN(new Date(startDateTime.value)) && (
<FormGroup
name="runOn"
fieldId="schedule-run-on"
helperTextInvalid={runOnMeta.error}
isRequired
isValid={!runOnMeta.touched || !runOnMeta.error}
label={i18n._(t`Run on`)}
>
<Radio
id="run-on-number"
name="runOn"
label={generateRunOnNumberLabel()}
value="number"
isChecked={runOn.value === 'number'}
onChange={(value, event) => {
event.target.value = 'number';
runOn.onChange(event);
}}
/>
<Radio
id="run-on-day"
name="runOn"
label={generateRunOnDayLabel()}
value="day"
isChecked={runOn.value === 'day'}
onChange={(value, event) => {
event.target.value = 'day';
runOn.onChange(event);
}}
/>
{new Date(startDateTime.value).getDate() >
getDaysInMonth(startDateTime.value) - 7 && (
<Radio
id="run-on-last-day"
name="runOn"
label={generateRunOnLastDayLabel()}
value="lastDay"
isChecked={runOn.value === 'lastDay'}
onChange={(value, event) => {
event.target.value = 'lastDay';
runOn.onChange(event);
}}
/>
)}
</FormGroup>
)}
<FormGroup
name="end"
fieldId="schedule-end"
helperTextInvalid={endMeta.error}
isRequired
isValid={!endMeta.touched || !endMeta.error}
label={i18n._(t`End`)}
>
<Radio
id="end-never"
name="end"
label={i18n._(t`Never`)}
value="never"
isChecked={end.value === 'never'}
onChange={(value, event) => {
event.target.value = 'never';
end.onChange(event);
}}
/>
<Radio
id="end-after"
name="end"
label={i18n._(t`After number of occurrences`)}
value="after"
isChecked={end.value === 'after'}
onChange={(value, event) => {
event.target.value = 'after';
end.onChange(event);
}}
/>
<Radio
id="end-on-date"
name="end"
label={i18n._(t`On date`)}
value="onDate"
isChecked={end.value === 'onDate'}
onChange={(value, event) => {
event.target.value = 'onDate';
end.onChange(event);
}}
/>
</FormGroup>
{end?.value === 'after' && (
<FormField
id="schedule-occurrences"
label={i18n._(t`Occurrences`)}
name="occurrences"
type="number"
min="1"
step="1"
validate={required(null, i18n)}
isRequired
/>
)}
{end?.value === 'onDate' && (
<FormGroup
fieldId="schedule-end-datetime"
helperTextInvalid={endDateTimeMeta.error}
isRequired
isValid={!endDateTimeMeta.touched || !endDateTimeMeta.error}
label={i18n._(t`End date/time`)}
>
<input
className="pf-c-form-control"
type="datetime-local"
id="schedule-end-datetime"
step="1"
{...endDateTime}
/>
</FormGroup>
)}
</>
);
/* eslint-enable no-restricted-globals */
};
export default withI18n()(FrequencyDetailSubform);

View File

@ -0,0 +1,230 @@
import React, { useEffect, useCallback } from 'react';
import { shape, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Formik, useField } from 'formik';
import { Config } from '@contexts/Config';
import { Form, FormGroup, Title } from '@patternfly/react-core';
import { SchedulesAPI } from '@api';
import AnsibleSelect from '@components/AnsibleSelect';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import FormActionGroup from '@components/FormActionGroup/FormActionGroup';
import FormField, { FormSubmitError } from '@components/FormField';
import { FormColumnLayout, SubFormLayout } from '@components/FormLayout';
import { dateToInputDateTime } from '@util/dates';
import useRequest from '@util/useRequest';
import { required } from '@util/validators';
import FrequencyDetailSubform from './FrequencyDetailSubform';
function ScheduleFormFields({ i18n, zoneOptions }) {
const [startDateTime, startDateTimeMeta] = useField({
name: 'startDateTime',
validate: required(
i18n._(t`Select a valid date and time for this field`),
i18n
),
});
const [timezone, timezoneMeta] = useField({
name: 'timezone',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const [frequency, frequencyMeta] = useField({
name: 'frequency',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
return (
<>
<FormField
id="schedule-name"
label={i18n._(t`Name`)}
name="name"
type="text"
validate={required(null, i18n)}
isRequired
/>
<FormField
id="schedule-description"
label={i18n._(t`Description`)}
name="description"
type="text"
/>
<FormGroup
fieldId="schedule-start-datetime"
helperTextInvalid={startDateTimeMeta.error}
isRequired
isValid={!startDateTimeMeta.touched || !startDateTimeMeta.error}
label={i18n._(t`Start date/time`)}
>
<input
className="pf-c-form-control"
type="datetime-local"
id="schedule-start-datetime"
step="1"
{...startDateTime}
/>
</FormGroup>
<FormGroup
name="timezone"
fieldId="schedule-timezone"
helperTextInvalid={timezoneMeta.error}
isRequired
isValid={!timezoneMeta.touched || !timezoneMeta.error}
label={i18n._(t`Local time zone`)}
>
<AnsibleSelect
id="schedule-timezone"
data={zoneOptions}
{...timezone}
/>
</FormGroup>
<FormGroup
name="frequency"
fieldId="schedule-requency"
helperTextInvalid={frequencyMeta.error}
isRequired
isValid={!frequencyMeta.touched || !frequencyMeta.error}
label={i18n._(t`Run frequency`)}
>
<AnsibleSelect
id="schedule-frequency"
data={[
{ value: 'none', key: 'none', label: i18n._(t`None (run once)`) },
{ value: 'minute', key: 'minute', label: i18n._(t`Minute`) },
{ value: 'hour', key: 'hour', label: i18n._(t`Hour`) },
{ value: 'day', key: 'day', label: i18n._(t`Day`) },
{ value: 'week', key: 'week', label: i18n._(t`Week`) },
{ value: 'month', key: 'month', label: i18n._(t`Month`) },
{ value: 'year', key: 'year', label: i18n._(t`Year`) },
]}
{...frequency}
/>
</FormGroup>
{frequency.value !== 'none' && (
<SubFormLayout>
<Title size="md">{i18n._(t`Frequency Details`)}</Title>
<FormColumnLayout>
<FrequencyDetailSubform />
</FormColumnLayout>
</SubFormLayout>
)}
</>
);
}
function ScheduleForm({
handleCancel,
handleSubmit,
i18n,
schedule,
submitError,
...rest
}) {
const {
request: loadZoneInfo,
error: contentError,
contentLoading,
result: zoneOptions,
} = useRequest(
useCallback(async () => {
const { data } = await SchedulesAPI.readZoneInfo();
return data.map(zone => {
return {
value: zone.name,
key: zone.name,
label: zone.name,
};
});
}, [])
);
useEffect(() => {
loadZoneInfo();
}, [loadZoneInfo]);
if (contentError) {
return <ContentError error={contentError} />;
}
if (contentLoading) {
return <ContentLoading />;
}
return (
<Config>
{() => {
const now = new Date();
const closestQuarterHour = new Date(
Math.ceil(now.getTime() / 900000) * 900000
);
const tomorrow = new Date(closestQuarterHour);
tomorrow.setDate(tomorrow.getDate() + 1);
return (
<Formik
initialValues={{
daysOfWeek: [],
description: schedule.description || '',
end: 'never',
endDateTime: dateToInputDateTime(tomorrow),
frequency: 'none',
interval: 1,
name: schedule.name || '',
occurrences: 1,
runOn: 'number',
startDateTime: dateToInputDateTime(closestQuarterHour),
timezone: schedule.timezone || 'America/New_York',
}}
onSubmit={handleSubmit}
validate={values => {
const errors = {};
const { end, endDateTime, startDateTime } = values;
if (
end === 'onDate' &&
new Date(startDateTime) > new Date(endDateTime)
) {
errors.endDateTime = i18n._(
t`Please select an end date/time that comes after the start date/time.`
);
}
return errors;
}}
>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<ScheduleFormFields
i18n={i18n}
zoneOptions={zoneOptions}
{...rest}
/>
<FormSubmitError error={submitError} />
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
/>
</FormColumnLayout>
</Form>
)}
</Formik>
);
}}
</Config>
);
}
ScheduleForm.propTypes = {
handleCancel: func.isRequired,
handleSubmit: func.isRequired,
schedule: shape({}),
submitError: shape(),
};
ScheduleForm.defaultProps = {
schedule: {},
submitError: null,
};
export default withI18n()(ScheduleForm);

View File

@ -0,0 +1,415 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { SchedulesAPI } from '@api';
import ScheduleForm from './ScheduleForm';
jest.mock('@api/models/Schedules');
let wrapper;
const defaultFieldsVisible = () => {
expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Start date/time"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Local time zone"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Run frequency"]').length).toBe(1);
};
describe('<ScheduleForm />', () => {
describe('Error', () => {
test('should display error when error occurs while loading', async () => {
SchedulesAPI.readZoneInfo.mockRejectedValue(
new Error({
response: {
config: {
method: 'get',
url: '/api/v2/schedules/zoneinfo',
},
data: 'An error occurred',
status: 500,
},
})
);
await act(async () => {
wrapper = mountWithContexts(
<ScheduleForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
);
});
wrapper.update();
expect(wrapper.find('ContentError').length).toBe(1);
});
});
describe('Cancel', () => {
test('should make the appropriate callback', async () => {
const handleCancel = jest.fn();
SchedulesAPI.readZoneInfo.mockResolvedValue({
data: [
{
name: 'America/New_York',
},
],
});
await act(async () => {
wrapper = mountWithContexts(
<ScheduleForm handleSubmit={jest.fn()} handleCancel={handleCancel} />
);
});
wrapper.update();
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').simulate('click');
});
expect(handleCancel).toHaveBeenCalledTimes(1);
});
});
describe('Add', () => {
beforeAll(async () => {
SchedulesAPI.readZoneInfo.mockResolvedValue({
data: [
{
name: 'America/New_York',
},
],
});
await act(async () => {
wrapper = mountWithContexts(
<ScheduleForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
);
});
});
afterAll(() => {
wrapper.unmount();
});
test('initially renders expected fields and values', () => {
expect(wrapper.find('ScheduleForm').length).toBe(1);
defaultFieldsVisible();
expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(0);
expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0);
expect(wrapper.find('FormGroup[label="Run on"]').length).toBe(0);
expect(wrapper.find('FormGroup[label="End"]').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('input#schedule-name').prop('value')).toBe('');
expect(wrapper.find('input#schedule-description').prop('value')).toBe('');
expect(
wrapper.find('input#schedule-start-datetime').prop('value')
).toMatch(/\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d/);
expect(wrapper.find('select#schedule-timezone').prop('value')).toBe(
'America/New_York'
);
expect(wrapper.find('select#schedule-frequency').prop('value')).toBe(
'none'
);
});
test('correct frequency details fields and values shown when frequency changed to minute', async () => {
const runFrequencySelect = wrapper.find(
'FormGroup[label="Run frequency"] FormSelect'
);
await act(async () => {
runFrequencySelect.invoke('onChange')('minute', {
target: { value: 'minute', key: 'minute', label: 'Minute' },
});
});
wrapper.update();
defaultFieldsVisible();
expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="End"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0);
expect(wrapper.find('FormGroup[label="Run on"]').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('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);
});
test('correct frequency details fields and values shown when frequency changed to hour', async () => {
const runFrequencySelect = wrapper.find(
'FormGroup[label="Run frequency"] FormSelect'
);
await act(async () => {
runFrequencySelect.invoke('onChange')('hour', {
target: { value: 'hour', key: 'hour', label: 'Hour' },
});
});
wrapper.update();
defaultFieldsVisible();
expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="End"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0);
expect(wrapper.find('FormGroup[label="Run on"]').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('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);
});
test('correct frequency details fields and values shown when frequency changed to day', async () => {
const runFrequencySelect = wrapper.find(
'FormGroup[label="Run frequency"] FormSelect'
);
await act(async () => {
runFrequencySelect.invoke('onChange')('day', {
target: { value: 'day', key: 'day', label: 'Day' },
});
});
wrapper.update();
defaultFieldsVisible();
expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="End"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0);
expect(wrapper.find('FormGroup[label="Run on"]').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('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);
});
test('correct frequency details fields and values shown when frequency changed to week', async () => {
const runFrequencySelect = wrapper.find(
'FormGroup[label="Run frequency"] FormSelect'
);
await act(async () => {
runFrequencySelect.invoke('onChange')('week', {
target: { value: 'week', key: 'week', label: 'Week' },
});
});
wrapper.update();
defaultFieldsVisible();
expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="End"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="On days"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Run on"]').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('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);
});
test('correct frequency details fields and values shown when frequency changed to month', async () => {
const runFrequencySelect = wrapper.find(
'FormGroup[label="Run frequency"] FormSelect'
);
await act(async () => {
runFrequencySelect.invoke('onChange')('month', {
target: { value: 'month', key: 'month', label: 'Month' },
});
});
wrapper.update();
defaultFieldsVisible();
expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="End"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0);
expect(wrapper.find('FormGroup[label="Run on"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0);
expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0);
expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1);
expect(wrapper.find('input#end-never').prop('checked')).toBe(true);
expect(wrapper.find('input#end-after').prop('checked')).toBe(false);
expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false);
});
test('month run on options displayed correctly as date changes', async () => {
await act(async () => {
wrapper.find('input#schedule-start-datetime').simulate('change', {
target: { value: '2020-03-23T01:45:00', name: 'startDateTime' },
});
});
wrapper.update();
expect(wrapper.find('input#run-on-number').prop('checked')).toBe(true);
expect(wrapper.find('input#run-on-number + label').text()).toBe('Day 23');
expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false);
expect(wrapper.find('input#run-on-day + label').text()).toBe(
'The fourth Monday'
);
expect(wrapper.find('input#run-on-last-day').length).toBe(0);
await act(async () => {
wrapper.find('input#schedule-start-datetime').simulate('change', {
target: { value: '2020-03-27T01:45:00', name: 'startDateTime' },
});
});
wrapper.update();
expect(wrapper.find('input#run-on-number').prop('checked')).toBe(true);
expect(wrapper.find('input#run-on-number + label').text()).toBe('Day 27');
expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false);
expect(wrapper.find('input#run-on-day + label').text()).toBe(
'The fourth Friday'
);
expect(wrapper.find('input#run-on-last-day').prop('checked')).toBe(false);
expect(wrapper.find('input#run-on-last-day + label').text()).toBe(
'The last Friday'
);
});
test('month run on cleared when last day selected but date changes from one of the last seven days of the month', async () => {
await act(async () => {
wrapper.find('Radio#run-on-last-day').invoke('onChange')('lastDay', {
target: { name: 'runOn' },
});
});
wrapper.update();
expect(wrapper.find('input#run-on-number').prop('checked')).toBe(false);
expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false);
expect(wrapper.find('input#run-on-last-day').prop('checked')).toBe(true);
await act(async () => {
wrapper.find('input#schedule-start-datetime').simulate('change', {
target: { value: '2020-03-15T01:45:00', name: 'startDateTime' },
});
});
wrapper.update();
expect(wrapper.find('input#run-on-number').prop('checked')).toBe(false);
expect(wrapper.find('input#run-on-number + label').text()).toBe('Day 15');
expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false);
expect(wrapper.find('input#run-on-day + label').text()).toBe(
'The third Sunday'
);
expect(wrapper.find('input#run-on-last-day').length).toBe(0);
await act(async () => {
wrapper.find('Radio#run-on-number').invoke('onChange')('number', {
target: { name: 'runOn' },
});
});
wrapper.update();
});
test('correct frequency details fields and values shown when frequency changed to year', async () => {
const runFrequencySelect = wrapper.find(
'FormGroup[label="Run frequency"] FormSelect'
);
await act(async () => {
runFrequencySelect.invoke('onChange')('year', {
target: { value: 'year', key: 'year', label: 'Year' },
});
});
wrapper.update();
defaultFieldsVisible();
expect(wrapper.find('FormGroup[label="Run every"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="End"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="On days"]').length).toBe(0);
expect(wrapper.find('FormGroup[label="Run on"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(0);
expect(wrapper.find('FormGroup[label="End date/time"]').length).toBe(0);
expect(wrapper.find('input#schedule-run-every').prop('value')).toBe(1);
expect(wrapper.find('input#end-never').prop('checked')).toBe(true);
expect(wrapper.find('input#end-after').prop('checked')).toBe(false);
expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false);
});
test('year run on options displayed correctly as date changes', async () => {
await act(async () => {
wrapper.find('input#schedule-start-datetime').simulate('change', {
target: { value: '2020-03-23T01:45:00', name: 'startDateTime' },
});
});
wrapper.update();
expect(wrapper.find('input#run-on-number').prop('checked')).toBe(true);
expect(wrapper.find('input#run-on-number + label').text()).toBe(
'March 23'
);
expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false);
expect(wrapper.find('input#run-on-day + label').text()).toBe(
'The fourth Monday in March'
);
expect(wrapper.find('input#run-on-last-day').length).toBe(0);
await act(async () => {
wrapper.find('input#schedule-start-datetime').simulate('change', {
target: { value: '2020-03-27T01:45:00', name: 'startDateTime' },
});
});
wrapper.update();
expect(wrapper.find('input#run-on-number').prop('checked')).toBe(true);
expect(wrapper.find('input#run-on-number + label').text()).toBe(
'March 27'
);
expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false);
expect(wrapper.find('input#run-on-day + label').text()).toBe(
'The fourth Friday in March'
);
expect(wrapper.find('input#run-on-last-day').prop('checked')).toBe(false);
expect(wrapper.find('input#run-on-last-day + label').text()).toBe(
'The last Friday in March'
);
});
test('occurrences field properly shown when that run on selection is made', async () => {
await act(async () => {
wrapper.find('Radio#end-after').invoke('onChange')('after', {
target: { name: 'end' },
});
});
wrapper.update();
expect(wrapper.find('input#end-never').prop('checked')).toBe(false);
expect(wrapper.find('input#end-after').prop('checked')).toBe(true);
expect(wrapper.find('input#end-on-date').prop('checked')).toBe(false);
expect(wrapper.find('FormGroup[label="Occurrences"]').length).toBe(1);
expect(wrapper.find('input#schedule-occurrences').prop('value')).toBe(1);
await act(async () => {
wrapper.find('Radio#end-never').invoke('onChange')('never', {
target: { name: 'end' },
});
});
wrapper.update();
});
test('year run on cleared when last day selected but date changes from one of the last seven days of the month', async () => {
await act(async () => {
wrapper.find('Radio#run-on-last-day').invoke('onChange')('lastDay', {
target: { name: 'runOn' },
});
});
wrapper.update();
expect(wrapper.find('input#run-on-number').prop('checked')).toBe(false);
expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false);
expect(wrapper.find('input#run-on-last-day').prop('checked')).toBe(true);
await act(async () => {
wrapper.find('input#schedule-start-datetime').simulate('change', {
target: { value: '2020-03-15T01:45:00', name: 'startDateTime' },
});
});
wrapper.update();
expect(wrapper.find('input#run-on-number').prop('checked')).toBe(false);
expect(wrapper.find('input#run-on-number + label').text()).toBe(
'March 15'
);
expect(wrapper.find('input#run-on-day').prop('checked')).toBe(false);
expect(wrapper.find('input#run-on-day + label').text()).toBe(
'The third Sunday in March'
);
expect(wrapper.find('input#run-on-last-day').length).toBe(0);
});
test('error shown when end date/time comes before start date/time', async () => {
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 () => {
wrapper.find('Radio#end-on-date').invoke('onChange')('onDate', {
target: { name: 'end' },
});
});
wrapper.update();
expect(wrapper.find('input#end-never').prop('checked')).toBe(false);
expect(wrapper.find('input#end-after').prop('checked')).toBe(false);
expect(wrapper.find('input#end-on-date').prop('checked')).toBe(true);
expect(wrapper.find('#schedule-end-datetime-helper').length).toBe(0);
await act(async () => {
wrapper.find('input#schedule-end-datetime').invoke('onChange')(
'2020-03-14T01:45:00',
{
target: { name: 'endDateTime' },
}
);
});
wrapper.update();
setTimeout(() => {
expect(wrapper.find('#schedule-end-datetime-helper').text()).toBe(
'Please select an end date/time that comes after the start date/time.'
);
});
});
});
});

View File

@ -26,6 +26,7 @@ class Project extends Component {
isInitialized: false,
isNotifAdmin: false,
};
this.createSchedule = this.createSchedule.bind(this);
this.loadProject = this.loadProject.bind(this);
this.loadProjectAndRoles = this.loadProjectAndRoles.bind(this);
this.loadSchedules = this.loadSchedules.bind(this);
@ -91,6 +92,11 @@ class Project extends Component {
}
}
createSchedule(data) {
const { project } = this.state;
return ProjectsAPI.createSchedule(project.id, data);
}
loadScheduleOptions() {
const { project } = this.state;
return ProjectsAPI.readScheduleOptions(project.id);
@ -233,6 +239,7 @@ class Project extends Component {
<Schedules
setBreadcrumb={setBreadcrumb}
unifiedJobTemplate={project}
createSchedule={this.createSchedule}
loadSchedules={this.loadSchedules}
loadScheduleOptions={this.loadScheduleOptions}
/>

View File

@ -66,22 +66,7 @@ describe('<ProjectDetail />', () => {
});
test('should render Details', () => {
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />, {
context: {
linguiPublisher: {
i18n: {
_: key => {
if (key.values) {
Object.entries(key.values).forEach(([k, v]) => {
key.id = key.id.replace(new RegExp(`\\{${k}\\}`), v);
});
}
return key.id;
},
},
},
},
});
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
function assertDetail(label, value) {
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);

View File

@ -44,6 +44,7 @@ class Projects extends Component {
[`/projects/${project.id}/job_templates`]: i18n._(t`Job Templates`),
[`${projectSchedulesPath}`]: i18n._(t`Schedules`),
[`${projectSchedulesPath}/add`]: i18n._(t`Create New Schedule`),
[`${projectSchedulesPath}/${nested?.id}`]: `${nested?.name}`,
[`${projectSchedulesPath}/${nested?.id}/details`]: i18n._(
t`Edit Details`

View File

@ -59,6 +59,10 @@ function Template({ i18n, me, setBreadcrumb }) {
loadTemplateAndRoles();
}, [loadTemplateAndRoles, location.pathname]);
const createSchedule = data => {
return JobTemplatesAPI.createSchedule(templateId, data);
};
const loadScheduleOptions = () => {
return JobTemplatesAPI.readScheduleOptions(templateId);
};
@ -173,6 +177,7 @@ function Template({ i18n, me, setBreadcrumb }) {
path="/templates/:templateType/:id/schedules"
>
<Schedules
createSchedule={createSchedule}
setBreadcrumb={setBreadcrumb}
unifiedJobTemplate={template}
loadSchedules={loadSchedules}

View File

@ -62,6 +62,9 @@ class Templates extends Component {
[`/templates/${template.type}/${template.id}/schedules`]: i18n._(
t`Schedules`
),
[`/templates/${template.type}/${template.id}/schedules/add`]: i18n._(
t`Create New Schedule`
),
[`/templates/${template.type}/${template.id}/schedules/${schedule &&
schedule.id}`]: `${schedule && schedule.name}`,
[`/templates/${template.type}/${template.id}/schedules/${schedule &&

View File

@ -34,6 +34,7 @@ class WorkflowJobTemplate extends Component {
webhook_key: null,
isNotifAdmin: false,
};
this.createSchedule = this.createSchedule.bind(this);
this.loadTemplate = this.loadTemplate.bind(this);
this.loadSchedules = this.loadSchedules.bind(this);
this.loadScheduleOptions = this.loadScheduleOptions.bind(this);
@ -91,6 +92,11 @@ class WorkflowJobTemplate extends Component {
}
}
createSchedule(data) {
const { template } = this.state;
return WorkflowJobTemplatesAPI.createSchedule(template.id, data);
}
loadScheduleOptions() {
const { template } = this.state;
return WorkflowJobTemplatesAPI.readScheduleOptions(template.id);
@ -271,6 +277,7 @@ class WorkflowJobTemplate extends Component {
<Schedules
setBreadcrumb={setBreadcrumb}
unifiedJobTemplate={template}
createSchedule={this.createSchedule}
loadSchedules={this.loadSchedules}
loadScheduleOptions={this.loadScheduleOptions}
/>

View File

@ -14,22 +14,7 @@ describe('<UserDetail />', () => {
});
test('should render Details', () => {
const wrapper = mountWithContexts(<UserDetail user={mockDetails} />, {
context: {
linguiPublisher: {
i18n: {
_: key => {
if (key.values) {
Object.entries(key.values).forEach(([k, v]) => {
key.id = key.id.replace(new RegExp(`\\{${k}\\}`), v);
});
}
return key.id;
},
},
},
},
});
const wrapper = mountWithContexts(<UserDetail user={mockDetails} />);
function assertDetail(label, value) {
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);

View File

@ -1,6 +1,9 @@
/* eslint-disable import/prefer-default-export */
import { t } from '@lingui/macro';
import { getLanguage } from './language';
const prependZeros = value => value.toString().padStart(2, 0);
export function formatDateString(dateString, lang = getLanguage(navigator)) {
return new Date(dateString).toLocaleString(lang);
}
@ -12,3 +15,100 @@ export function formatDateStringUTC(dateString, lang = getLanguage(navigator)) {
export function secondsToHHMMSS(seconds) {
return new Date(seconds * 1000).toISOString().substr(11, 8);
}
export function dateToInputDateTime(dateObj) {
// input type="date-time" expects values to be formatted
// like: YYYY-MM-DDTHH-MM-SS
const year = dateObj.getFullYear();
const month = prependZeros(dateObj.getMonth() + 1);
const day = prependZeros(dateObj.getDate());
const hour = prependZeros(dateObj.getHours());
const minute = prependZeros(dateObj.getMinutes());
const second = prependZeros(dateObj.getSeconds());
return `${year}-${month}-${day}T${hour}:${minute}:${second}`;
}
export function getDaysInMonth(dateString) {
const dateObj = new Date(dateString);
return new Date(dateObj.getFullYear(), dateObj.getMonth() + 1, 0).getDate();
}
export function getWeekNumber(dateString) {
const dateObj = new Date(dateString);
const dayOfMonth = dateObj.getDate();
const dayOfWeek = dateObj.getDay();
if (dayOfMonth < 8) {
return 1;
}
dateObj.setDate(dayOfMonth - dayOfWeek + 1);
return Math.ceil(dayOfMonth / 7);
}
export function getDayString(dayIndex, i18n) {
switch (dayIndex) {
case 0:
return i18n._(t`Sunday`);
case 1:
return i18n._(t`Monday`);
case 2:
return i18n._(t`Tuesday`);
case 3:
return i18n._(t`Wednesday`);
case 4:
return i18n._(t`Thursday`);
case 5:
return i18n._(t`Friday`);
case 6:
return i18n._(t`Saturday`);
default:
throw new Error(i18n._(t`Unrecognized day index`));
}
}
export function getWeekString(weekNumber, i18n) {
switch (weekNumber) {
case 1:
return i18n._(t`first`);
case 2:
return i18n._(t`second`);
case 3:
return i18n._(t`third`);
case 4:
return i18n._(t`fourth`);
case 5:
return i18n._(t`fifth`);
default:
throw new Error(i18n._(t`Unrecognized week number`));
}
}
export function getMonthString(monthIndex, i18n) {
switch (monthIndex) {
case 0:
return i18n._(t`January`);
case 1:
return i18n._(t`February`);
case 2:
return i18n._(t`March`);
case 3:
return i18n._(t`April`);
case 4:
return i18n._(t`May`);
case 5:
return i18n._(t`June`);
case 6:
return i18n._(t`July`);
case 7:
return i18n._(t`August`);
case 8:
return i18n._(t`September`);
case 9:
return i18n._(t`October`);
case 10:
return i18n._(t`November`);
case 11:
return i18n._(t`December`);
default:
throw new Error(i18n._(t`Unrecognized month index`));
}
}

View File

@ -1,4 +1,25 @@
import { formatDateString } from './dates';
import {
dateToInputDateTime,
getDaysInMonth,
getDayString,
getMonthString,
getWeekNumber,
getWeekString,
formatDateString,
formatDateStringUTC,
secondsToHHMMSS,
} from './dates';
const i18n = {
_: key => {
if (key.values) {
Object.entries(key.values).forEach(([k, v]) => {
key.id = key.id.replace(new RegExp(`\\{${k}\\}`), v);
});
}
return key.id;
},
};
describe('formatDateString', () => {
test('it returns the expected value', () => {
@ -11,3 +32,90 @@ describe('formatDateString', () => {
);
});
});
describe('formatDateStringUTC', () => {
test('it returns the expected value', () => {
const lang = 'en-US';
expect(formatDateStringUTC('', lang)).toEqual('Invalid Date');
expect(formatDateStringUTC({}, lang)).toEqual('Invalid Date');
expect(formatDateStringUTC(undefined, lang)).toEqual('Invalid Date');
expect(formatDateStringUTC('2018-01-31T01:14:52.969227Z', lang)).toEqual(
'1/31/2018, 1:14:52 AM'
);
});
});
describe('secondsToHHMMSS', () => {
test('it returns the expected value', () => {
expect(secondsToHHMMSS(50000)).toEqual('13:53:20');
});
});
describe('dateToInputDateTime', () => {
test('it returns the expected value', () => {
expect(
dateToInputDateTime(new Date('2018-01-31T01:14:52.969227Z'))
).toEqual('2018-01-31T01:14:52');
});
});
describe('getDaysInMonth', () => {
test('it returns the expected value', () => {
expect(getDaysInMonth('2020-02-15T00:00:00Z')).toEqual(29);
expect(getDaysInMonth('2020-03-15T00:00:00Z')).toEqual(31);
expect(getDaysInMonth('2020-04-15T00:00:00Z')).toEqual(30);
});
});
describe('getWeekNumber', () => {
test('it returns the expected value', () => {
expect(getWeekNumber('2020-02-01T00:00:00Z')).toEqual(1);
expect(getWeekNumber('2020-02-08T00:00:00Z')).toEqual(2);
expect(getWeekNumber('2020-02-15T00:00:00Z')).toEqual(3);
expect(getWeekNumber('2020-02-22T00:00:00Z')).toEqual(4);
expect(getWeekNumber('2020-02-29T00:00:00Z')).toEqual(5);
});
});
describe('getDayString', () => {
test('it returns the expected value', () => {
expect(getDayString(0, i18n)).toEqual('Sunday');
expect(getDayString(1, i18n)).toEqual('Monday');
expect(getDayString(2, i18n)).toEqual('Tuesday');
expect(getDayString(3, i18n)).toEqual('Wednesday');
expect(getDayString(4, i18n)).toEqual('Thursday');
expect(getDayString(5, i18n)).toEqual('Friday');
expect(getDayString(6, i18n)).toEqual('Saturday');
expect(() => getDayString(7, i18n)).toThrow();
});
});
describe('getWeekString', () => {
test('it returns the expected value', () => {
expect(() => getWeekString(0, i18n)).toThrow();
expect(getWeekString(1, i18n)).toEqual('first');
expect(getWeekString(2, i18n)).toEqual('second');
expect(getWeekString(3, i18n)).toEqual('third');
expect(getWeekString(4, i18n)).toEqual('fourth');
expect(getWeekString(5, i18n)).toEqual('fifth');
expect(() => getWeekString(6, i18n)).toThrow();
});
});
describe('getMonthString', () => {
test('it returns the expected value', () => {
expect(getMonthString(0, i18n)).toEqual('January');
expect(getMonthString(1, i18n)).toEqual('February');
expect(getMonthString(2, i18n)).toEqual('March');
expect(getMonthString(3, i18n)).toEqual('April');
expect(getMonthString(4, i18n)).toEqual('May');
expect(getMonthString(5, i18n)).toEqual('June');
expect(getMonthString(6, i18n)).toEqual('July');
expect(getMonthString(7, i18n)).toEqual('August');
expect(getMonthString(8, i18n)).toEqual('September');
expect(getMonthString(9, i18n)).toEqual('October');
expect(getMonthString(10, i18n)).toEqual('November');
expect(getMonthString(11, i18n)).toEqual('December');
expect(() => getMonthString(12, i18n)).toThrow();
});
});

View File

@ -27,7 +27,14 @@ const defaultContexts = {
linguiPublisher: {
i18n: {
...originalI18n,
_: key => key.id, // provide _ macro, for just passing down the key
_: key => {
if (key.values) {
Object.entries(key.values).forEach(([k, v]) => {
key.id = key.id.replace(new RegExp(`\\{${k}\\}`), v);
});
}
return key.id;
}, // provide _ macro, for just passing down the key
toJSON: () => '/i18n/',
},
},