1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-30 13:55:31 +03:00

Merge pull request #6170 from mabashian/5859-jt-schedule-details

Adds generic schedule detail component and applies it to JT/WFJT/Proj schedules

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-03-06 20:44:11 +00:00 committed by GitHub
commit f4366be419
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1038 additions and 36 deletions

View File

@ -13202,6 +13202,12 @@
"yallist": "^2.1.2"
}
},
"luxon": {
"version": "1.22.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-1.22.0.tgz",
"integrity": "sha512-3sLvlfbFo+AxVEY3IqxymbumtnlgBwjDExxK60W3d+trrUzErNAz/PfvPT+mva+vEUrdIodeCOs7fB6zHtRSrw==",
"optional": true
},
"make-dir": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
@ -16217,6 +16223,22 @@
"inherits": "^2.0.1"
}
},
"rrule": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.6.4.tgz",
"integrity": "sha512-sLdnh4lmjUqq8liFiOUXD5kWp/FcnbDLPwq5YAc/RrN6120XOPb86Ae5zxF7ttBVq8O3LxjjORMEit1baluahA==",
"requires": {
"luxon": "^1.21.3",
"tslib": "^1.10.0"
},
"dependencies": {
"tslib": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz",
"integrity": "sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA=="
}
}
},
"rst-selector-parser": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz",

View File

@ -77,6 +77,7 @@
"react-dom": "^16.13.0",
"react-router-dom": "^5.1.2",
"react-virtualized": "^9.21.1",
"rrule": "^2.6.4",
"styled-components": "^4.2.0"
}
}

View File

@ -5,6 +5,14 @@ class Schedules extends Base {
super(http);
this.baseUrl = '/api/v2/schedules/';
}
createPreview(data) {
return this.http.post(`${this.baseUrl}preview/`, data);
}
readCredentials(resourceId, params) {
return this.http.get(`${this.baseUrl}${resourceId}/credentials/`, params);
}
}
export default Schedules;

View File

@ -21,6 +21,7 @@ function MultiButtonToggle({ buttons, value, onChange }) {
{buttons &&
buttons.map(([buttonValue, buttonLabel]) => (
<SmallButton
aria-label={buttonLabel}
key={buttonLabel}
onClick={() => setValue(buttonValue)}
variant={buttonValue === value ? 'primary' : 'secondary'}

View File

@ -0,0 +1,140 @@
import React, { useEffect, useState } from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import {
Switch,
Route,
Link,
Redirect,
useLocation,
useParams,
} from 'react-router-dom';
import { CardActions } from '@patternfly/react-core';
import { CaretLeftIcon } from '@patternfly/react-icons';
import CardCloseButton from '@components/CardCloseButton';
import RoutedTabs from '@components/RoutedTabs';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import { TabbedCardHeader } from '@components/Card';
import { ScheduleDetail } from '@components/Schedule';
import { SchedulesAPI } from '@api';
function Schedule({ i18n, setBreadcrumb, unifiedJobTemplate }) {
const [schedule, setSchedule] = useState(null);
const [contentLoading, setContentLoading] = useState(true);
const [contentError, setContentError] = useState(null);
const { scheduleId } = useParams();
const location = useLocation();
const { pathname } = location;
const pathRoot = pathname.substr(0, pathname.indexOf('schedules'));
useEffect(() => {
const loadData = async () => {
try {
const { data } = await SchedulesAPI.readDetail(scheduleId);
setSchedule(data);
setBreadcrumb(unifiedJobTemplate, data);
} catch (err) {
setContentError(err);
} finally {
setContentLoading(false);
}
};
loadData();
}, [location.pathname, scheduleId, unifiedJobTemplate, setBreadcrumb]);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Schedules`)}
</>
),
link: `${pathRoot}schedules`,
id: 99,
},
{
name: i18n._(t`Details`),
link: `${pathRoot}schedules/${schedule && schedule.id}/details`,
id: 0,
},
];
if (contentLoading) {
return <ContentLoading />;
}
if (
schedule.summary_fields.unified_job_template.id !==
parseInt(unifiedJobTemplate.id, 10)
) {
return (
<ContentError>
{schedule && (
<Link to={`${pathRoot}schedules`}>{i18n._(t`View Schedules`)}</Link>
)}
</ContentError>
);
}
if (contentError) {
return <ContentError error={contentError} />;
}
let cardHeader = null;
if (
location.pathname.includes('schedules/') &&
!location.pathname.endsWith('edit')
) {
cardHeader = (
<TabbedCardHeader>
<RoutedTabs tabsArray={tabsArray} />
<CardActions>
<CardCloseButton linkTo={`${pathRoot}schedules`} />
</CardActions>
</TabbedCardHeader>
);
}
return (
<>
{cardHeader}
<Switch>
<Redirect
from={`${pathRoot}schedules/:scheduleId`}
to={`${pathRoot}schedules/:scheduleId/details`}
exact
/>
{schedule && [
<Route
key="details"
path={`${pathRoot}schedules/:scheduleId/details`}
render={() => {
return <ScheduleDetail schedule={schedule} />;
}}
/>,
]}
<Route
key="not-found"
path="*"
render={() => {
return (
<ContentError>
{unifiedJobTemplate && (
<Link to={`${pathRoot}details`}>
{i18n._(t`View Details`)}
</Link>
)}
</ContentError>
);
}}
/>
</Switch>
</>
);
}
export { Schedule as _Schedule };
export default withI18n()(Schedule);

View File

@ -0,0 +1,110 @@
import React from 'react';
import { SchedulesAPI } from '@api';
import { Route } from 'react-router-dom';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import Schedule from './Schedule';
jest.mock('@api/models/Schedules');
SchedulesAPI.readDetail.mockResolvedValue({
data: {
url: '/api/v2/schedules/1',
rrule:
'DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1',
id: 1,
summary_fields: {
unified_job_template: {
id: 1,
name: 'Mock JT',
description: '',
unified_job_type: 'job',
},
user_capabilities: {
edit: true,
delete: true,
},
created_by: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
modified_by: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
},
created: '2020-03-03T20:38:54.210306Z',
modified: '2020-03-03T20:38:54.210336Z',
name: 'Mock JT Schedule',
next_run: '2020-02-20T05:00:00Z',
},
});
SchedulesAPI.createPreview.mockResolvedValue({
data: {
local: [],
utc: [],
},
});
SchedulesAPI.readCredentials.mockResolvedValue({
data: {
count: 0,
results: [],
},
});
describe('<Schedule />', () => {
let wrapper;
let history;
const unifiedJobTemplate = { id: 1, name: 'Mock JT' };
beforeAll(async () => {
history = createMemoryHistory({
initialEntries: ['/templates/job_template/1/schedules/1/details'],
});
await act(async () => {
wrapper = mountWithContexts(
<Route
path="/templates/job_template/:id/schedules"
component={() => (
<Schedule
setBreadcrumb={() => {}}
unifiedJobTemplate={unifiedJobTemplate}
/>
)}
/>,
{
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
},
},
},
},
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterAll(() => {
wrapper.unmount();
});
test('renders successfully', async () => {
expect(wrapper.length).toBe(1);
});
test('expect all tabs to exist, including Back to Schedules', async () => {
expect(
wrapper.find('button[link="/templates/job_template/1/schedules"]').length
).toBe(1);
expect(wrapper.find('button[aria-label="Details"]').length).toBe(1);
});
});

View File

@ -0,0 +1,202 @@
import React, { useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { rrulestr } from 'rrule';
import styled from 'styled-components';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Schedule } from '@types';
import { Chip, ChipGroup, Title } from '@patternfly/react-core';
import { CardBody } from '@components/Card';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import CredentialChip from '@components/CredentialChip';
import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
import { ScheduleOccurrences, ScheduleToggle } from '@components/Schedule';
import { formatDateString } from '@util/dates';
import useRequest from '@util/useRequest';
import { SchedulesAPI } from '@api';
const PromptTitle = styled(Title)`
--pf-c-title--m-md--FontWeight: 700;
`;
function ScheduleDetail({ schedule, i18n }) {
const {
id,
created,
description,
diff_mode,
dtend,
dtstart,
job_tags,
job_type,
inventory,
limit,
modified,
name,
next_run,
rrule,
scm_branch,
skip_tags,
summary_fields,
timezone,
} = schedule;
const {
result: [credentials, preview],
isLoading,
error,
request: fetchCredentialsAndPreview,
} = useRequest(
useCallback(async () => {
const [{ data }, { data: schedulePreview }] = await Promise.all([
SchedulesAPI.readCredentials(id),
SchedulesAPI.createPreview({
rrule,
}),
]);
return [data.results, schedulePreview];
}, [id, rrule]),
[]
);
useEffect(() => {
fetchCredentialsAndPreview();
}, [fetchCredentialsAndPreview]);
const rule = rrulestr(rrule);
const repeatFrequency =
rule.options.freq === 3 && dtstart === dtend
? i18n._(t`None (Run Once)`)
: rule.toText().replace(/^\w/, c => c.toUpperCase());
const showPromptedFields =
(credentials && credentials.length > 0) ||
job_type ||
(inventory && summary_fields.inventory) ||
scm_branch ||
limit ||
typeof diff_mode === 'boolean' ||
(job_tags && job_tags.length > 0) ||
(skip_tags && skip_tags.length > 0);
if (isLoading) {
return <ContentLoading />;
}
if (error) {
return <ContentError error={error} />;
}
return (
<CardBody>
<ScheduleToggle schedule={schedule} css="padding-bottom: 40px" />
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
<Detail
label={i18n._(t`First Run`)}
value={formatDateString(dtstart)}
/>
<Detail
label={i18n._(t`Next Run`)}
value={formatDateString(next_run)}
/>
<Detail label={i18n._(t`Last Run`)} value={formatDateString(dtend)} />
<Detail label={i18n._(t`Local Time Zone`)} value={timezone} />
<Detail label={i18n._(t`Repeat Frequency`)} value={repeatFrequency} />
<ScheduleOccurrences preview={preview} />
<UserDateDetail
label={i18n._(t`Created`)}
date={created}
user={summary_fields.created_by}
/>
<UserDateDetail
label={i18n._(t`Last Modified`)}
date={modified}
user={summary_fields.modified_by}
/>
{showPromptedFields && (
<>
<PromptTitle size="md" css="grid-column: 1 / -1;">
{i18n._(t`Prompted Fields`)}
</PromptTitle>
<Detail label={i18n._(t`Job Type`)} value={job_type} />
{inventory && summary_fields.inventory && (
<Detail
label={i18n._(t`Inventory`)}
value={
<Link
to={`/inventories/${
summary_fields.inventory.kind === 'smart'
? 'smart_inventory'
: 'inventory'
}/${summary_fields.inventory.id}/details`}
>
{summary_fields.inventory.name}
</Link>
}
/>
)}
<Detail label={i18n._(t`SCM Branch`)} value={scm_branch} />
<Detail label={i18n._(t`Limit`)} value={limit} />
{typeof diff_mode === 'boolean' && (
<Detail
label={i18n._(t`Show Changes`)}
value={diff_mode ? 'On' : 'Off'}
/>
)}
{credentials && credentials.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Credentials`)}
value={
<ChipGroup numChips={5}>
{credentials.map(c => (
<CredentialChip key={c.id} credential={c} isReadOnly />
))}
</ChipGroup>
}
/>
)}
{job_tags && job_tags.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Job Tags`)}
value={
<ChipGroup numChips={5}>
{job_tags.split(',').map(jobTag => (
<Chip key={jobTag} isReadOnly>
{jobTag}
</Chip>
))}
</ChipGroup>
}
/>
)}
{skip_tags && skip_tags.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Skip Tags`)}
value={
<ChipGroup numChips={5}>
{skip_tags.split(',').map(skipTag => (
<Chip key={skipTag} isReadOnly>
{skipTag}
</Chip>
))}
</ChipGroup>
}
/>
)}
</>
)}
</DetailList>
</CardBody>
);
}
ScheduleDetail.propTypes = {
schedule: Schedule.isRequired,
};
export default withI18n()(ScheduleDetail);

View File

@ -0,0 +1,261 @@
import React from 'react';
import { SchedulesAPI } from '@api';
import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import ScheduleDetail from './ScheduleDetail';
jest.mock('@api/models/Schedules');
const schedule = {
url: '/api/v2/schedules/1',
rrule:
'DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1',
id: 1,
summary_fields: {
unified_job_template: {
id: 1,
name: 'Mock JT',
description: '',
unified_job_type: 'job',
},
user_capabilities: {
edit: true,
delete: true,
},
created_by: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
modified_by: {
id: 1,
username: 'admin',
first_name: '',
last_name: '',
},
inventory: {
id: 1,
name: 'Test Inventory',
},
},
created: '2020-03-03T20:38:54.210306Z',
modified: '2020-03-03T20:38:54.210336Z',
name: 'Mock JT Schedule',
enabled: false,
description: 'A good schedule',
timezone: 'America/New_York',
dtstart: '2020-03-16T04:00:00Z',
dtend: '2020-07-06T04:00:00Z',
next_run: '2020-03-16T04:00:00Z',
};
SchedulesAPI.createPreview.mockResolvedValue({
data: {
local: [],
utc: [],
},
});
describe('<ScheduleDetail />', () => {
let wrapper;
const history = createMemoryHistory({
initialEntries: ['/templates/job_template/1/schedules/1/details'],
});
afterEach(() => {
wrapper.unmount();
});
test('details should render with the proper values without prompts', async () => {
SchedulesAPI.readCredentials.mockResolvedValueOnce({
data: {
count: 0,
results: [],
},
});
await act(async () => {
wrapper = mountWithContexts(
<Route
path="/templates/job_template/:id/schedules/:scheduleId"
component={() => <ScheduleDetail schedule={schedule} />}
/>,
{
context: {
router: {
history,
route: {
location: history.location,
match: { params: { id: 1 } },
},
},
},
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(
wrapper
.find('Detail[label="Name"]')
.find('dd')
.text()
).toBe('Mock JT Schedule');
expect(
wrapper
.find('Detail[label="Description"]')
.find('dd')
.text()
).toBe('A good schedule');
expect(wrapper.find('Detail[label="First Run"]').length).toBe(1);
expect(wrapper.find('Detail[label="Next Run"]').length).toBe(1);
expect(wrapper.find('Detail[label="Last Run"]').length).toBe(1);
expect(
wrapper
.find('Detail[label="Local Time Zone"]')
.find('dd')
.text()
).toBe('America/New_York');
expect(wrapper.find('Detail[label="Repeat Frequency"]').length).toBe(1);
expect(wrapper.find('Detail[label="Created"]').length).toBe(1);
expect(wrapper.find('Detail[label="Last Modified"]').length).toBe(1);
expect(wrapper.find('Title[children="Prompted Fields"]').length).toBe(0);
expect(wrapper.find('Detail[label="Job Type"]').length).toBe(0);
expect(wrapper.find('Detail[label="Inventory"]').length).toBe(0);
expect(wrapper.find('Detail[label="SCM Branch"]').length).toBe(0);
expect(wrapper.find('Detail[label="Limit"]').length).toBe(0);
expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(0);
expect(wrapper.find('Detail[label="Credentials"]').length).toBe(0);
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0);
expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(0);
});
test('details should render with the proper values with prompts', async () => {
SchedulesAPI.readCredentials.mockResolvedValueOnce({
data: {
count: 2,
results: [
{
id: 1,
name: 'Cred 1',
},
{
id: 2,
name: 'Cred 2',
},
],
},
});
const scheduleWithPrompts = {
...schedule,
job_type: 'run',
inventory: 1,
job_tags: 'tag1',
skip_tags: 'tag2',
scm_branch: 'foo/branch',
limit: 'localhost',
diff_mode: true,
verbosity: 1,
};
await act(async () => {
wrapper = mountWithContexts(
<Route
path="/templates/job_template/:id/schedules/:scheduleId"
component={() => <ScheduleDetail schedule={scheduleWithPrompts} />}
/>,
{
context: {
router: {
history,
route: {
location: history.location,
match: { params: { id: 1 } },
},
},
},
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(
wrapper
.find('Detail[label="Name"]')
.find('dd')
.text()
).toBe('Mock JT Schedule');
expect(
wrapper
.find('Detail[label="Description"]')
.find('dd')
.text()
).toBe('A good schedule');
expect(wrapper.find('Detail[label="First Run"]').length).toBe(1);
expect(wrapper.find('Detail[label="Next Run"]').length).toBe(1);
expect(wrapper.find('Detail[label="Last Run"]').length).toBe(1);
expect(
wrapper
.find('Detail[label="Local Time Zone"]')
.find('dd')
.text()
).toBe('America/New_York');
expect(wrapper.find('Detail[label="Repeat Frequency"]').length).toBe(1);
expect(wrapper.find('Detail[label="Created"]').length).toBe(1);
expect(wrapper.find('Detail[label="Last Modified"]').length).toBe(1);
expect(wrapper.find('Title[children="Prompted Fields"]').length).toBe(1);
expect(
wrapper
.find('Detail[label="Job Type"]')
.find('dd')
.text()
).toBe('run');
expect(wrapper.find('Detail[label="Inventory"]').length).toBe(1);
expect(
wrapper
.find('Detail[label="SCM Branch"]')
.find('dd')
.text()
).toBe('foo/branch');
expect(
wrapper
.find('Detail[label="Limit"]')
.find('dd')
.text()
).toBe('localhost');
expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(1);
expect(wrapper.find('Detail[label="Credentials"]').length).toBe(1);
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(1);
expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(1);
});
test('error shown when error encountered fetching credentials', async () => {
SchedulesAPI.readCredentials.mockRejectedValueOnce(
new Error({
response: {
config: {
method: 'get',
url: '/api/v2/job_templates/1/schedules/1/credentials',
},
data: 'An error occurred',
status: 500,
},
})
);
await act(async () => {
wrapper = mountWithContexts(
<Route
path="/templates/job_template/:id/schedules/:scheduleId"
component={() => <ScheduleDetail schedule={schedule} />}
/>,
{
context: {
router: {
history,
route: {
location: history.location,
match: { params: { id: 1 } },
},
},
},
}
);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
});

View File

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

View File

@ -3,7 +3,7 @@ import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { SchedulesAPI } from '@api';
import ScheduleList from './ScheduleList';
import mockSchedules from './data.schedules.json';
import mockSchedules from '../data.schedules.json';
jest.mock('@api/models/Schedules');

View File

@ -15,10 +15,10 @@ import {
} from '@patternfly/react-core';
import { PencilAltIcon } from '@patternfly/react-icons';
import { DetailList, Detail } from '@components/DetailList';
import { ScheduleToggle } from '@components/Schedule';
import styled from 'styled-components';
import { Schedule } from '@types';
import { formatDateString } from '@util/dates';
import ScheduleToggle from './ScheduleToggle';
const DataListAction = styled(_DataListAction)`
align-items: center;

View File

@ -0,0 +1,79 @@
import React, { useState } from 'react';
import { shape } from 'prop-types';
import styled from 'styled-components';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { formatDateString, formatDateStringUTC } from '@util/dates';
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
import { DetailName, DetailValue } from '@components/DetailList';
import MultiButtonToggle from '@components/MultiButtonToggle';
const OccurrencesLabel = styled.div`
display: inline-block;
font-size: var(--pf-c-form__label--FontSize);
font-weight: var(--pf-c-form__label--FontWeight);
line-height: var(--pf-c-form__label--LineHeight);
color: var(--pf-c-form__label--Color);
span:first-of-type {
font-weight: var(--pf-global--FontWeight--bold);
margin-right: 10px;
}
`;
function ScheduleOccurrences({ preview = { local: [], utc: [] }, i18n }) {
const [mode, setMode] = useState('local');
if (preview.local.length < 2) {
return null;
}
return (
<>
<DetailName
component={TextListItemVariants.dt}
fullWidth
css="grid-column: 1 / -1"
>
<Split gutter="sm">
<SplitItem>
<OccurrencesLabel>
<span>{i18n._(t`Occurrences`)}</span>
<span>{i18n._(t`(Limited to first 10)`)}</span>
</OccurrencesLabel>
</SplitItem>
<SplitItem>
<MultiButtonToggle
buttons={[['local', 'Local'], ['utc', 'UTC']]}
value={mode}
onChange={newMode => setMode(newMode)}
/>
</SplitItem>
</Split>
</DetailName>
<DetailValue
component={TextListItemVariants.dd}
fullWidth
css="grid-column: 1 / -1; margin-top: -10px"
>
{preview[mode].map(dateStr => (
<div key={dateStr}>
{mode === 'local'
? formatDateString(dateStr)
: formatDateStringUTC(dateStr)}
</div>
))}
</DetailValue>
</>
);
}
ScheduleOccurrences.propTypes = {
preview: shape(),
};
ScheduleOccurrences.defaultProps = {
preview: { local: [], utc: [] },
};
export default withI18n()(ScheduleOccurrences);

View File

@ -0,0 +1,61 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import ScheduleOccurrences from './ScheduleOccurrences';
describe('<ScheduleOccurrences>', () => {
let wrapper;
describe('At least two dates passed in', () => {
beforeAll(() => {
wrapper = mountWithContexts(
<ScheduleOccurrences
preview={{
local: ['2020-03-16T00:00:00-04:00', '2020-03-30T00:00:00-04:00'],
utc: ['2020-03-16T04:00:00Z', '2020-03-30T04:00:00Z'],
}}
/>
);
});
afterAll(() => {
wrapper.unmount();
});
test('Local option initially set', async () => {
expect(wrapper.find('MultiButtonToggle').props().value).toBe('local');
});
test('It renders the correct number of dates', async () => {
expect(wrapper.find('dd').children().length).toBe(2);
});
test('Clicking UTC button toggles the dates to utc', async () => {
wrapper.find('button[aria-label="UTC"]').simulate('click');
expect(wrapper.find('MultiButtonToggle').props().value).toBe('utc');
expect(wrapper.find('dd').children().length).toBe(2);
expect(
wrapper
.find('dd')
.children()
.at(0)
.text()
).toBe('3/16/2020, 4:00:00 AM');
expect(
wrapper
.find('dd')
.children()
.at(1)
.text()
).toBe('3/30/2020, 4:00:00 AM');
});
});
describe('Only one date passed in', () => {
test('Component should not render chldren', async () => {
wrapper = mountWithContexts(
<ScheduleOccurrences
preview={{
local: ['2020-03-16T00:00:00-04:00'],
utc: ['2020-03-16T04:00:00Z'],
}}
/>
);
expect(wrapper.find('ScheduleOccurrences').children().length).toBe(0);
wrapper.unmount();
});
});
});

View File

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

View File

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

View File

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

View File

@ -0,0 +1,33 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import Schedules from './Schedules';
describe('<Schedules />', () => {
test('initially renders successfully', async () => {
let wrapper;
const history = createMemoryHistory({
initialEntries: ['/templates/job_template/1/schedules'],
});
const jobTemplate = { id: 1, name: 'Mock JT' };
await act(async () => {
wrapper = mountWithContexts(
<Schedules
setBreadcrumb={() => {}}
jobTemplate={jobTemplate}
loadSchedules={() => {}}
loadScheduleOptions={() => {}}
/>,
{
context: {
router: { history, route: { location: history.location } },
},
}
);
});
expect(wrapper.length).toBe(1);
});
});

View File

@ -0,0 +1,6 @@
export { default as Schedule } from './Schedule';
export { default as Schedules } from './Schedules';
export { default as ScheduleList } from './ScheduleList';
export { default as ScheduleOccurrences } from './ScheduleOccurrences';
export { default as ScheduleToggle } from './ScheduleToggle';
export { default as ScheduleDetail } from './ScheduleDetail';

View File

@ -9,7 +9,7 @@ import RoutedTabs from '@components/RoutedTabs';
import ContentError from '@components/ContentError';
import NotificationList from '@components/NotificationList';
import { ResourceAccessList } from '@components/ResourceAccessList';
import ScheduleList from '@components/ScheduleList';
import { Schedules } from '@components/Schedule';
import ProjectDetail from './ProjectDetail';
import ProjectEdit from './ProjectEdit';
import ProjectJobTemplatesList from './ProjectJobTemplatesList';
@ -116,7 +116,7 @@ class Project extends Component {
}
render() {
const { location, match, me, i18n } = this.props;
const { location, match, me, i18n, setBreadcrumb } = this.props;
const {
project,
@ -175,7 +175,10 @@ class Project extends Component {
cardHeader = null;
}
if (location.pathname.endsWith('edit')) {
if (
location.pathname.endsWith('edit') ||
location.pathname.includes('schedules/')
) {
cardHeader = null;
}
@ -247,7 +250,9 @@ class Project extends Component {
<Route
path="/projects/:id/schedules"
render={() => (
<ScheduleList
<Schedules
setBreadcrumb={setBreadcrumb}
unifiedJobTemplate={project}
loadSchedules={this.loadSchedules}
loadScheduleOptions={this.loadScheduleOptions}
/>

View File

@ -4,11 +4,11 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import Breadcrumbs from '@components/Breadcrumbs';
import ScheduleList from '@components/ScheduleList';
import { ScheduleList } from '@components/Schedule';
import { SchedulesAPI } from '@api';
import { PageSection, Card } from '@patternfly/react-core';
function Schedules({ i18n }) {
function AllSchedules({ i18n }) {
const loadScheduleOptions = () => {
return SchedulesAPI.readOptions();
};
@ -41,4 +41,4 @@ function Schedules({ i18n }) {
);
}
export default withI18n()(Schedules);
export default withI18n()(AllSchedules);

View File

@ -1,9 +1,9 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { createMemoryHistory } from 'history';
import Schedules from './Schedules';
import AllSchedules from './AllSchedules';
describe('<Schedules />', () => {
describe('<AllSchedules />', () => {
let wrapper;
afterEach(() => {
@ -11,7 +11,7 @@ describe('<Schedules />', () => {
});
test('initially renders succesfully', () => {
wrapper = mountWithContexts(<Schedules />);
wrapper = mountWithContexts(<AllSchedules />);
});
test('should display schedule list breadcrumb heading', () => {
@ -19,7 +19,7 @@ describe('<Schedules />', () => {
initialEntries: ['/schedules'],
});
wrapper = mountWithContexts(<Schedules />, {
wrapper = mountWithContexts(<AllSchedules />, {
context: {
router: {
history,

View File

@ -1 +1 @@
export { default } from './Schedules';
export { default } from './AllSchedules';

View File

@ -10,7 +10,7 @@ import ContentError from '@components/ContentError';
import JobList from '@components/JobList';
import NotificationList from '@components/NotificationList';
import RoutedTabs from '@components/RoutedTabs';
import ScheduleList from '@components/ScheduleList';
import { Schedules } from '@components/Schedule';
import { ResourceAccessList } from '@components/ResourceAccessList';
import JobTemplateDetail from './JobTemplateDetail';
import JobTemplateEdit from './JobTemplateEdit';
@ -96,7 +96,7 @@ class Template extends Component {
}
render() {
const { i18n, location, match, me } = this.props;
const { i18n, location, match, me, setBreadcrumb } = this.props;
const {
contentError,
hasContentLoading,
@ -126,6 +126,10 @@ class Template extends Component {
}
tabsArray.push(
{
name: i18n._(t`Schedules`),
link: `${match.url}/schedules`,
},
{
name: i18n._(t`Completed Jobs`),
link: `${match.url}/completed_jobs`,
@ -149,7 +153,10 @@ class Template extends Component {
</TabbedCardHeader>
);
if (location.pathname.endsWith('edit')) {
if (
location.pathname.endsWith('edit') ||
location.pathname.includes('schedules/')
) {
cardHeader = null;
}
@ -211,6 +218,20 @@ class Template extends Component {
)}
/>
)}
{template && (
<Route
key="schedules"
path="/templates/:templateType/:id/schedules"
render={() => (
<Schedules
setBreadcrumb={setBreadcrumb}
unifiedJobTemplate={template}
loadSchedules={this.loadSchedules}
loadScheduleOptions={this.loadScheduleOptions}
/>
)}
/>
)}
{canSeeNotificationsTab && (
<Route
path="/templates/:templateType/:id/notifications"
@ -228,17 +249,6 @@ class Template extends Component {
<JobList defaultParams={{ job__job_template: template.id }} />
</Route>
)}
{template && (
<Route
path="/templates/:templateType/:id/schedules"
render={() => (
<ScheduleList
loadSchedules={this.loadSchedules}
loadScheduleOptions={this.loadScheduleOptions}
/>
)}
/>
)}
{template && (
<Route
path="/templates/:templateType/:id/survey"

View File

@ -58,10 +58,11 @@ describe('<Template />', () => {
const wrapper = mountWithContexts(
<Template setBreadcrumb={() => {}} me={mockMe} />
);
const tabs = await waitForElement(
wrapper,
'.pf-c-tabs__item',
el => el.length === 6
el => el.length === 7
);
expect(tabs.at(2).text()).toEqual('Notifications');
done();

View File

@ -27,7 +27,7 @@ class Templates extends Component {
};
}
setBreadCrumbConfig = template => {
setBreadCrumbConfig = (template, schedule) => {
const { i18n } = this.props;
if (!template) {
return;
@ -53,6 +53,13 @@ class Templates extends Component {
t`Completed Jobs`
),
[`/templates/${template.type}/${template.id}/survey`]: i18n._(t`Survey`),
[`/templates/${template.type}/${template.id}/schedules`]: i18n._(
t`Schedules`
),
[`/templates/${template.type}/${template.id}/schedules/${schedule &&
schedule.id}`]: `${schedule && schedule.name}`,
[`/templates/${template.type}/${template.id}/schedules/${schedule &&
schedule.id}/details`]: i18n._(t`Schedule Details`),
};
this.setState({ breadcrumbConfig });
};

View File

@ -10,7 +10,7 @@ import ContentError from '@components/ContentError';
import FullPage from '@components/FullPage';
import JobList from '@components/JobList';
import RoutedTabs from '@components/RoutedTabs';
import ScheduleList from '@components/ScheduleList';
import { Schedules } from '@components/Schedule';
import ContentLoading from '@components/ContentLoading';
import { WorkflowJobTemplatesAPI, CredentialsAPI } from '@api';
import WorkflowJobTemplateDetail from './WorkflowJobTemplateDetail';
@ -88,7 +88,7 @@ class WorkflowJobTemplate extends Component {
}
render() {
const { i18n, location, match } = this.props;
const { i18n, location, match, setBreadcrumb } = this.props;
const {
contentError,
hasContentLoading,
@ -152,7 +152,10 @@ class WorkflowJobTemplate extends Component {
return (
<PageSection>
<Card>
{location.pathname.endsWith('edit') ? null : cardHeader}
{location.pathname.endsWith('edit') ||
location.pathname.includes('schedules/')
? null
: cardHeader}
<Switch>
<Redirect
from="/templates/workflow_job_template/:id"
@ -207,9 +210,11 @@ class WorkflowJobTemplate extends Component {
)}
{template?.id && (
<Route
path="/templates/:templateType/:id/schedules"
path="/templates/workflow_job_template/:id/schedules"
render={() => (
<ScheduleList
<Schedules
setBreadcrumb={setBreadcrumb}
unifiedJobTemplate={template}
loadSchedules={this.loadSchedules}
loadScheduleOptions={this.loadScheduleOptions}
/>

View File

@ -291,7 +291,7 @@ export const Schedule = shape({
skip_tags: string,
limit: string,
diff_mode: bool,
verbosity: string,
verbosity: number,
unified_job_template: number,
enabled: bool,
dtstart: string,

View File

@ -5,6 +5,10 @@ export function formatDateString(dateString, lang = getLanguage(navigator)) {
return new Date(dateString).toLocaleString(lang);
}
export function formatDateStringUTC(dateString, lang = getLanguage(navigator)) {
return new Date(dateString).toLocaleString(lang, { timeZone: 'UTC' });
}
export function secondsToHHMMSS(seconds) {
return new Date(seconds * 1000).toISOString().substr(11, 8);
}