mirror of
https://github.com/ansible/awx.git
synced 2026-02-14 17:50:02 -03:30
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:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
151
awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx
Normal file
151
awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx
Normal 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);
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/components/Schedule/ScheduleAdd/index.js
Normal file
1
awx/ui_next/src/components/Schedule/ScheduleAdd/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './ScheduleAdd';
|
||||
@@ -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 =
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
253
awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx
Normal file
253
awx/ui_next/src/components/Schedule/shared/ScheduleForm.jsx
Normal 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);
|
||||
315
awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx
Normal file
315
awx/ui_next/src/components/Schedule/shared/ScheduleForm.test.jsx
Normal 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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/',
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user