Merge pull request #6419 from mabashian/5864-schedule-add-2

Implement schedule add form on JT/WFJT/Proj

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-03-31 16:06:46 +00:00
committed by GitHub
24 changed files with 1753 additions and 56 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

@@ -25,7 +25,16 @@ class AnsibleSelect extends React.Component {
}
render() {
const { id, data, i18n, isValid, onBlur, value, className } = this.props;
const {
id,
data,
i18n,
isValid,
onBlur,
value,
className,
isDisabled,
} = this.props;
return (
<FormSelect
@@ -36,6 +45,7 @@ class AnsibleSelect extends React.Component {
aria-label={i18n._(t`Select Input`)}
isValid={isValid}
className={className}
isDisabled={isDisabled}
>
{data.map(option => (
<FormSelectOption
@@ -62,6 +72,7 @@ AnsibleSelect.defaultProps = {
isValid: true,
onBlur: () => {},
className: '',
isDisabled: false,
};
AnsibleSelect.propTypes = {
@@ -72,6 +83,7 @@ AnsibleSelect.propTypes = {
onChange: func.isRequired,
value: oneOfType([string, number]).isRequired,
className: string,
isDisabled: bool,
};
export { AnsibleSelect as _AnsibleSelect };

View File

@@ -17,6 +17,8 @@ function FormSubmitError({ error }) {
setErrorMessage(errorMessages.__all__);
} else if (errorMessages.detail) {
setErrorMessage(errorMessages.detail);
} else if (errorMessages.resources_needed_to_start) {
setErrorMessage(errorMessages.resources_needed_to_start);
} else {
setErrorMessage(null);
}
@@ -31,7 +33,17 @@ function FormSubmitError({ error }) {
return null;
}
return <Alert variant="danger" isInline title={errorMessage} />;
return (
<Alert
variant="danger"
isInline
title={
Array.isArray(errorMessage)
? errorMessage.map(msg => <div key={msg}>{msg}</div>)
: errorMessage
}
/>
);
}
export default FormSubmitError;

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,151 @@
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 { getRRuleDayConstants } from '@util/dates';
import ScheduleForm from '../shared/ScheduleForm';
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 buildRuleObj = values => {
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 === 'day') {
ruleObj.bymonthday = values.runOnDayNumber;
} else if (values.runOn === 'the') {
ruleObj.bysetpos = parseInt(values.runOnTheOccurrence, 10);
ruleObj.byweekday = getRRuleDayConstants(values.runOnTheDay, i18n);
}
break;
case 'year':
ruleObj.freq = RRule.YEARLY;
if (values.runOn === 'day') {
ruleObj.bymonth = parseInt(values.runOnDayMonth, 10);
ruleObj.bymonthday = values.runOnDayNumber;
} else if (values.runOn === 'the') {
ruleObj.bysetpos = parseInt(values.runOnTheOccurrence, 10);
ruleObj.byweekday = getRRuleDayConstants(values.runOnTheDay, i18n);
ruleObj.bymonth = parseInt(values.runOnTheMonth, 10);
}
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`));
}
return ruleObj;
};
const handleSubmit = async values => {
try {
const rule = new RRule(buildRuleObj(values));
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,255 @@
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,
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',
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',
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,
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: 'day',
runOnDayNumber: 1,
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=1',
});
});
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: 'the',
runOnTheDay: 'tuesday',
runOnTheOccurrence: -1,
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;BYSETPOS=-1;BYDAY=TU',
});
});
test('Successfully creates a schedule with yearly repeat frequency on the first day of March', async () => {
await act(async () => {
wrapper.find('ScheduleForm').invoke('handleSubmit')({
description: 'test description',
end: 'never',
frequency: 'year',
interval: 1,
name: 'Yearly on the first day of March',
occurrences: 1,
runOn: 'day',
runOnDayMonth: 3,
runOnDayNumber: 1,
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=1',
});
});
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: 'the',
runOnTheOccurrence: 2,
runOnTheDay: 'friday',
runOnTheMonth: 4,
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;BYSETPOS=2;BYDAY=FR;BYMONTH=4',
});
});
test('Successfully creates a schedule with yearly repeat frequency on the first weekday in October', async () => {
await act(async () => {
wrapper.find('ScheduleForm').invoke('handleSubmit')({
description: 'test description',
end: 'never',
frequency: 'year',
interval: 1,
name: 'Yearly on the first weekday in October',
occurrences: 1,
runOn: 'the',
runOnTheOccurrence: 1,
runOnTheDay: 'weekday',
runOnTheMonth: 10,
startDateTime: '2020-04-10T11:15',
timezone: 'America/New_York',
});
});
expect(createSchedule).toHaveBeenCalledWith({
description: 'test description',
name: 'Yearly on the first weekday in October',
rrule:
'DTSTART;TZID=America/New_York:20200410T111500 RRULE:INTERVAL=1;FREQ=YEARLY;BYSETPOS=1;BYDAY=MO,TU,WE,TH,FR;BYMONTH=10',
});
});
});

View File

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

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { rrulestr } from 'rrule';
import { RRule, rrulestr } from 'rrule';
import styled from 'styled-components';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
@@ -66,7 +66,7 @@ function ScheduleDetail({ schedule, i18n }) {
const rule = rrulestr(rrule);
const repeatFrequency =
rule.options.freq === 3 && dtstart === dtend
rule.options.freq === RRule.MINUTELY && dtstart === dtend
? i18n._(t`None (Run Once)`)
: rule.toText().replace(/^\w/, c => c.toUpperCase());
const showPromptedFields =

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,556 @@
import React 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 AnsibleSelect from '@components/AnsibleSelect';
import FormField from '@components/FormField';
import { required } from '@util/validators';
const RunOnRadio = styled(Radio)`
label {
display: block;
width: 100%;
}
:not(:last-of-type) {
margin-bottom: 10px;
}
select:not(:first-of-type) {
margin-left: 10px;
}
`;
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 [runOnDayMonth] = useField({
name: 'runOnDayMonth',
});
const [runOnDayNumber] = useField({
name: 'runOnDayNumber',
});
const [runOnTheOccurrence] = useField({
name: 'runOnTheOccurrence',
});
const [runOnTheDay] = useField({
name: 'runOnTheDay',
});
const [runOnTheMonth] = useField({
name: 'runOnTheMonth',
});
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] = 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),
});
const monthOptions = [
{
key: 'january',
value: 1,
label: i18n._(t`January`),
},
{
key: 'february',
value: 2,
label: i18n._(t`February`),
},
{
key: 'march',
value: 3,
label: i18n._(t`March`),
},
{
key: 'april',
value: 4,
label: i18n._(t`April`),
},
{
key: 'may',
value: 5,
label: i18n._(t`May`),
},
{
key: 'june',
value: 6,
label: i18n._(t`June`),
},
{
key: 'july',
value: 7,
label: i18n._(t`July`),
},
{ key: 'august', value: 8, label: i18n._(t`August`) },
{
key: 'september',
value: 9,
label: i18n._(t`September`),
},
{
key: 'october',
value: 10,
label: i18n._(t`October`),
},
{
key: 'november',
value: 11,
label: i18n._(t`November`),
},
{
key: 'december',
value: 12,
label: i18n._(t`December`),
},
];
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`));
}
};
/* 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="schedule-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="schedule-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="schedule-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="schedule-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="schedule-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="schedule-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="schedule-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`)}
>
<RunOnRadio
id="schedule-run-on-day"
name="runOn"
label={
<div css="display: flex;align-items: center;">
{frequency?.value === 'month' && (
<span id="foobar" css="margin-right: 10px;">
Day
</span>
)}
{frequency?.value === 'year' && (
<AnsibleSelect
id="schedule-run-on-day-month"
css="margin-right: 10px"
isDisabled={runOn.value !== 'day'}
data={monthOptions}
{...runOnDayMonth}
/>
)}
<TextInput
id="schedule-run-on-day-number"
type="number"
min="1"
max="31"
step="1"
isDisabled={runOn.value !== 'day'}
{...runOnDayNumber}
onChange={(value, event) => {
runOnDayNumber.onChange(event);
}}
/>
</div>
}
value="day"
isChecked={runOn.value === 'day'}
onChange={(value, event) => {
event.target.value = 'day';
runOn.onChange(event);
}}
/>
<RunOnRadio
id="schedule-run-on-the"
name="runOn"
label={
<div css="display: flex;align-items: center;">
<span id="foobar" css="margin-right: 10px;">
The
</span>
<AnsibleSelect
id="schedule-run-on-the-occurrence"
isDisabled={runOn.value !== 'the'}
data={[
{ value: 1, key: 'first', label: i18n._(t`First`) },
{
value: 2,
key: 'second',
label: i18n._(t`Second`),
},
{ value: 3, key: 'third', label: i18n._(t`Third`) },
{
value: 4,
key: 'fourth',
label: i18n._(t`Fourth`),
},
{ value: 5, key: 'fifth', label: i18n._(t`Fifth`) },
{ value: -1, key: 'last', label: i18n._(t`Last`) },
]}
{...runOnTheOccurrence}
/>
<AnsibleSelect
id="schedule-run-on-the-day"
isDisabled={runOn.value !== 'the'}
data={[
{
value: 'sunday',
key: 'sunday',
label: i18n._(t`Sunday`),
},
{
value: 'monday',
key: 'monday',
label: i18n._(t`Monday`),
},
{
value: 'tuesday',
key: 'tuesday',
label: i18n._(t`Tuesday`),
},
{
value: 'wednesday',
key: 'wednesday',
label: i18n._(t`Wednesday`),
},
{
value: 'thursday',
key: 'thursday',
label: i18n._(t`Thursday`),
},
{
value: 'friday',
key: 'friday',
label: i18n._(t`Friday`),
},
{
value: 'saturday',
key: 'saturday',
label: i18n._(t`Saturday`),
},
{ value: 'day', key: 'day', label: i18n._(t`Day`) },
{
value: 'weekday',
key: 'weekday',
label: i18n._(t`Weekday`),
},
{
value: 'weekendDay',
key: 'weekendDay',
label: i18n._(t`Weekend day`),
},
]}
{...runOnTheDay}
/>
{frequency?.value === 'year' && (
<AnsibleSelect
id="schedule-run-on-the-month"
isDisabled={runOn.value !== 'the'}
data={monthOptions}
{...runOnTheMonth}
/>
)}
</div>
}
value="the"
isChecked={runOn.value === 'the'}
onChange={(value, event) => {
event.target.value = 'the';
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,253 @@
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 now = new Date();
const closestQuarterHour = new Date(
Math.ceil(now.getTime() / 900000) * 900000
);
const tomorrow = new Date(closestQuarterHour);
tomorrow.setDate(tomorrow.getDate() + 1);
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>
{() => {
return (
<Formik
initialValues={{
daysOfWeek: [],
description: schedule.description || '',
end: 'never',
endDateTime: dateToInputDateTime(tomorrow),
frequency: 'none',
interval: 1,
name: schedule.name || '',
occurrences: 1,
runOn: 'day',
runOnDayMonth: 1,
runOnDayNumber: 1,
runOnTheDay: 'sunday',
runOnTheMonth: 1,
runOnTheOccurrence: 1,
startDateTime: dateToInputDateTime(closestQuarterHour),
timezone: schedule.timezone || 'America/New_York',
}}
onSubmit={handleSubmit}
validate={values => {
const errors = {};
const {
end,
endDateTime,
frequency,
runOn,
runOnDayNumber,
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.`
);
}
if (
(frequency === 'month' || frequency === 'year') &&
runOn === 'day' &&
(runOnDayNumber < 1 || runOnDayNumber > 31)
) {
errors.runOn = i18n._(
t`Please select a day number between 1 and 31`
);
}
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,315 @@
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);
expect(wrapper.find('input#schedule-run-on-day').prop('checked')).toBe(
true
);
expect(
wrapper.find('input#schedule-run-on-day-number').prop('value')
).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(0);
expect(wrapper.find('select#schedule-run-on-the-month').length).toBe(0);
});
test('correct frequency details fields and values shown when frequency changed to year', async () => {
const runFrequencySelect = wrapper.find(
'FormGroup[label="Run frequency"] FormSelect'
);
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);
expect(wrapper.find('input#schedule-run-on-day').prop('checked')).toBe(
true
);
expect(
wrapper.find('input#schedule-run-on-day-number').prop('value')
).toBe(1);
expect(wrapper.find('input#schedule-run-on-the').prop('checked')).toBe(
false
);
expect(wrapper.find('select#schedule-run-on-day-month').length).toBe(1);
expect(wrapper.find('select#schedule-run-on-the-month').length).toBe(1);
});
test('occurrences field properly shown when end after selection is made', async () => {
await act(async () => {
wrapper.find('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('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,10 @@
/* eslint-disable import/prefer-default-export */
import { t } from '@lingui/macro';
import { RRule } from 'rrule';
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 +16,50 @@ 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 getRRuleDayConstants(dayString, i18n) {
switch (dayString) {
case 'sunday':
return RRule.SU;
case 'monday':
return RRule.MO;
case 'tuesday':
return RRule.TU;
case 'wednesday':
return RRule.WE;
case 'thursday':
return RRule.TH;
case 'friday':
return RRule.FR;
case 'saturday':
return RRule.SA;
case 'day':
return [
RRule.MO,
RRule.TU,
RRule.WE,
RRule.TH,
RRule.FR,
RRule.SA,
RRule.SU,
];
case 'weekday':
return [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR];
case 'weekendDay':
return [RRule.SA, RRule.SU];
default:
throw new Error(i18n._(t`Unrecognized day string`));
}
}

View File

@@ -1,4 +1,22 @@
import { formatDateString } from './dates';
import { RRule } from 'rrule';
import {
dateToInputDateTime,
formatDateString,
formatDateStringUTC,
getRRuleDayConstants,
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 +29,62 @@ 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('getRRuleDayConstants', () => {
test('it returns the expected value', () => {
expect(getRRuleDayConstants('monday', i18n)).toEqual(RRule.MO);
expect(getRRuleDayConstants('tuesday', i18n)).toEqual(RRule.TU);
expect(getRRuleDayConstants('wednesday', i18n)).toEqual(RRule.WE);
expect(getRRuleDayConstants('thursday', i18n)).toEqual(RRule.TH);
expect(getRRuleDayConstants('friday', i18n)).toEqual(RRule.FR);
expect(getRRuleDayConstants('saturday', i18n)).toEqual(RRule.SA);
expect(getRRuleDayConstants('sunday', i18n)).toEqual(RRule.SU);
expect(getRRuleDayConstants('day', i18n)).toEqual([
RRule.MO,
RRule.TU,
RRule.WE,
RRule.TH,
RRule.FR,
RRule.SA,
RRule.SU,
]);
expect(getRRuleDayConstants('weekday', i18n)).toEqual([
RRule.MO,
RRule.TU,
RRule.WE,
RRule.TH,
RRule.FR,
]);
expect(getRRuleDayConstants('weekendDay', i18n)).toEqual([
RRule.SA,
RRule.SU,
]);
expect(() => getRRuleDayConstants('foobar', 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/',
},
},