Merge pull request #5937 from mabashian/5857-all-schedules

Adds the All Schedules list

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-02-24 14:40:06 +00:00
committed by GitHub
13 changed files with 997 additions and 37 deletions

View File

@@ -17,6 +17,7 @@ import Organizations from './models/Organizations';
import Projects from './models/Projects';
import ProjectUpdates from './models/ProjectUpdates';
import Root from './models/Root';
import Schedules from './models/Schedules';
import SystemJobs from './models/SystemJobs';
import Teams from './models/Teams';
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
@@ -46,6 +47,7 @@ const OrganizationsAPI = new Organizations();
const ProjectsAPI = new Projects();
const ProjectUpdatesAPI = new ProjectUpdates();
const RootAPI = new Root();
const SchedulesAPI = new Schedules();
const SystemJobsAPI = new SystemJobs();
const TeamsAPI = new Teams();
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
@@ -76,6 +78,7 @@ export {
ProjectsAPI,
ProjectUpdatesAPI,
RootAPI,
SchedulesAPI,
SystemJobsAPI,
TeamsAPI,
UnifiedJobTemplatesAPI,

View File

@@ -0,0 +1,10 @@
import Base from '../Base';
class Schedules extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/schedules/';
}
}
export default Schedules;

View File

@@ -0,0 +1,163 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { SchedulesAPI } from '@api';
import { Card, PageSection } from '@patternfly/react-core';
import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import DataListToolbar from '@components/DataListToolbar';
import PaginatedDataList, {
ToolbarDeleteButton,
} from '@components/PaginatedDataList';
import useRequest, { useDeleteItems } from '@util/useRequest';
import { getQSConfig, parseQueryString } from '@util/qs';
import { ScheduleListItem } from '.';
const QS_CONFIG = getQSConfig('schedule', {
page: 1,
page_size: 20,
order_by: 'unified_job_template__polymorphic_ctype__model',
});
function ScheduleList({ i18n }) {
const [selected, setSelected] = useState([]);
const location = useLocation();
const {
result: { schedules, itemCount },
error: contentError,
isLoading,
request: fetchSchedules,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const {
data: { count, results },
} = await SchedulesAPI.read(params);
return {
itemCount: count,
schedules: results,
};
}, [location]),
{
schedules: [],
itemCount: 0,
}
);
useEffect(() => {
fetchSchedules();
}, [fetchSchedules]);
const isAllSelected =
selected.length === schedules.length && selected.length > 0;
const {
isLoading: isDeleteLoading,
deleteItems: deleteJobs,
deletionError,
clearDeletionError,
} = useDeleteItems(
useCallback(async () => {
return Promise.all(selected.map(({ id }) => SchedulesAPI.destroy(id)));
}, [selected]),
{
qsConfig: QS_CONFIG,
allItemsSelected: isAllSelected,
fetchItems: fetchSchedules,
}
);
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...schedules] : []);
};
const handleSelect = row => {
if (selected.some(s => s.id === row.id)) {
setSelected(selected.filter(s => s.id !== row.id));
} else {
setSelected(selected.concat(row));
}
};
const handleDelete = async () => {
await deleteJobs();
setSelected([]);
};
return (
<PageSection>
<Card>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading || isDeleteLoading}
items={schedules}
itemCount={itemCount}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
renderItem={item => (
<ScheduleListItem
isSelected={selected.some(row => row.id === item.id)}
key={item.id}
onSelect={() => handleSelect(item)}
schedule={item}
/>
)}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
{
name: i18n._(t`Next Run`),
key: 'next_run',
},
{
name: i18n._(t`Type`),
key: 'unified_job_template__polymorphic_ctype__model',
},
]}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG}
additionalControls={[
<ToolbarDeleteButton
key="delete"
onDelete={handleDelete}
itemsToDelete={selected}
pluralizedItemName={i18n._(t`Schedules`)}
/>,
]}
/>
)}
/>
</Card>
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={clearDeletionError}
>
{i18n._(t`Failed to delete one or more schedules.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
)}
</PageSection>
);
}
export default withI18n()(ScheduleList);

View File

@@ -0,0 +1,163 @@
import React from 'react';
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';
jest.mock('@api/models/Schedules');
SchedulesAPI.destroy = jest.fn();
SchedulesAPI.update.mockResolvedValue({
data: mockSchedules.results[0],
});
describe('ScheduleList', () => {
let wrapper;
afterAll(() => {
jest.clearAllMocks();
});
describe('read call successful', () => {
beforeAll(async () => {
SchedulesAPI.read.mockResolvedValue({ data: mockSchedules });
await act(async () => {
wrapper = mountWithContexts(<ScheduleList />);
});
wrapper.update();
});
afterAll(() => {
wrapper.unmount();
});
test('should fetch schedules from api and render the list', () => {
expect(SchedulesAPI.read).toHaveBeenCalled();
expect(wrapper.find('ScheduleListItem').length).toBe(5);
});
test('should check and uncheck the row item', async () => {
expect(
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
).toBe(false);
await act(async () => {
wrapper
.find('DataListCheck[id="select-schedule-1"]')
.invoke('onChange')(true);
});
wrapper.update();
expect(
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
).toBe(true);
await act(async () => {
wrapper
.find('DataListCheck[id="select-schedule-1"]')
.invoke('onChange')(false);
});
wrapper.update();
expect(
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
).toBe(false);
});
test('should check all row items when select all is checked', async () => {
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(true);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(false);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
});
test('should call api delete schedules for each selected schedule', async () => {
await act(async () => {
wrapper
.find('DataListCheck[id="select-schedule-3"]')
.invoke('onChange')();
});
wrapper.update();
await act(async () => {
wrapper.find('ToolbarDeleteButton').invoke('onDelete')();
});
wrapper.update();
expect(SchedulesAPI.destroy).toHaveBeenCalledTimes(1);
});
test('should show error modal when schedule is not successfully deleted from api', async () => {
SchedulesAPI.destroy.mockImplementationOnce(() =>
Promise.reject(new Error())
);
expect(wrapper.find('Modal').length).toBe(0);
await act(async () => {
wrapper
.find('DataListCheck[id="select-schedule-2"]')
.invoke('onChange')();
});
wrapper.update();
await act(async () => {
wrapper.find('ToolbarDeleteButton').invoke('onDelete')();
});
wrapper.update();
expect(wrapper.find('Modal').length).toBe(1);
await act(async () => {
wrapper.find('ModalBoxCloseButton').invoke('onClose')();
});
wrapper.update();
expect(wrapper.find('Modal').length).toBe(0);
});
test('should call api update schedules when toggle clicked', async () => {
await act(async () => {
wrapper
.find('Switch[id="schedule-5-toggle"]')
.first()
.invoke('onChange')();
});
wrapper.update();
expect(SchedulesAPI.update).toHaveBeenCalledTimes(1);
});
test('should show error modal when schedule is not successfully updated on toggle', async () => {
SchedulesAPI.update.mockImplementationOnce(() =>
Promise.reject(new Error())
);
expect(wrapper.find('Modal').length).toBe(0);
await act(async () => {
wrapper
.find('Switch[id="schedule-1-toggle"]')
.first()
.invoke('onChange')();
});
wrapper.update();
expect(wrapper.find('Modal').length).toBe(1);
await act(async () => {
wrapper.find('ModalBoxCloseButton').invoke('onClose')();
});
wrapper.update();
expect(wrapper.find('Modal').length).toBe(0);
});
});
describe('read call unsuccessful', () => {
test('should show content error when read call unsuccessful', async () => {
SchedulesAPI.read.mockRejectedValue(new Error());
await act(async () => {
wrapper = mountWithContexts(<ScheduleList />);
});
wrapper.update();
expect(wrapper.find('ContentError').length).toBe(1);
});
});
});

View File

@@ -0,0 +1,133 @@
import React from 'react';
import { bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
import {
Button,
DataListAction as _DataListAction,
DataListCell,
DataListCheck,
DataListItem,
DataListItemRow,
DataListItemCells,
Tooltip,
} from '@patternfly/react-core';
import { PencilAltIcon } from '@patternfly/react-icons';
import { DetailList, Detail } from '@components/DetailList';
import styled from 'styled-components';
import { Schedule } from '@types';
import { formatDateString } from '@util/dates';
import ScheduleToggle from '../shared/ScheduleToggle';
const DataListAction = styled(_DataListAction)`
align-items: center;
display: grid;
grid-gap: 16px;
grid-template-columns: auto 40px;
`;
function ScheduleListItem({ i18n, isSelected, onSelect, schedule }) {
const labelId = `check-action-${schedule.id}`;
const jobTypeLabels = {
inventory_update: i18n._(t`Inventory Sync`),
job: i18n._(t`Playbook Run`),
project_update: i18n._(t`SCM Update`),
system_job: i18n._(t`Management Job`),
workflow_job: i18n._(t`Workflow Job`),
};
let scheduleBaseUrl;
switch (schedule.summary_fields.unified_job_template.unified_job_type) {
case 'inventory_update':
scheduleBaseUrl = `/inventories/${schedule.summary_fields.inventory.id}/sources/${schedule.summary_fields.unified_job_template.id}/schedules/${schedule.id}`;
break;
case 'job':
scheduleBaseUrl = `/templates/job_template/${schedule.summary_fields.unified_job_template.id}/schedules/${schedule.id}`;
break;
case 'project_update':
scheduleBaseUrl = `/projects/${schedule.summary_fields.unified_job_template.id}/schedules/${schedule.id}`;
break;
case 'system_job':
scheduleBaseUrl = `/management_jobs/${schedule.summary_fields.unified_job_template.id}/schedules/${schedule.id}`;
break;
case 'workflow_job':
scheduleBaseUrl = `/templates/workflow_job_template/${schedule.summary_fields.unified_job_template.id}/schedules/${schedule.id}`;
break;
default:
break;
}
return (
<DataListItem
key={schedule.id}
aria-labelledby={labelId}
id={`${schedule.id}`}
>
<DataListItemRow>
<DataListCheck
id={`select-schedule-${schedule.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="name">
<Link to={`${scheduleBaseUrl}/details`}>
<b>{schedule.name}</b>
</Link>
</DataListCell>,
<DataListCell key="type">
{
jobTypeLabels[
schedule.summary_fields.unified_job_template.unified_job_type
]
}
</DataListCell>,
<DataListCell key="next_run">
{schedule.next_run && (
<DetailList stacked>
<Detail
label={i18n._(t`Next Run`)}
value={formatDateString(schedule.next_run)}
/>
</DetailList>
)}
</DataListCell>,
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
key="actions"
>
<ScheduleToggle schedule={schedule} />
{schedule.summary_fields.user_capabilities.edit && (
<Tooltip content={i18n._(t`Edit Schedule`)} position="top">
<Button
css="grid-column: 2"
variant="plain"
component={Link}
to={`${scheduleBaseUrl}/edit`}
>
<PencilAltIcon />
</Button>
</Tooltip>
)}
</DataListAction>,
]}
/>
</DataListItemRow>
</DataListItem>
);
}
ScheduleListItem.propTypes = {
isSelected: bool.isRequired,
onSelect: func.isRequired,
schedule: Schedule.isRequired,
};
export default withI18n()(ScheduleListItem);

View File

@@ -0,0 +1,169 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import ScheduleListItem from './ScheduleListItem';
const mockSchedule = {
rrule:
'DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1',
id: 6,
type: 'schedule',
url: '/api/v2/schedules/6/',
related: {},
summary_fields: {
unified_job_template: {
id: 12,
name: 'Mock JT',
description: '',
unified_job_type: 'job',
},
user_capabilities: {
edit: true,
delete: true,
},
},
created: '2020-02-12T21:05:08.460029Z',
modified: '2020-02-12T21:05:52.840596Z',
name: 'Mock Schedule',
description: 'every day for 1 time',
extra_data: {},
inventory: null,
scm_branch: null,
job_type: null,
job_tags: null,
skip_tags: null,
limit: null,
diff_mode: null,
verbosity: null,
unified_job_template: 12,
enabled: true,
dtstart: '2020-02-20T05:00:00Z',
dtend: '2020-02-20T05:00:00Z',
next_run: '2020-02-20T05:00:00Z',
timezone: 'America/New_York',
until: '',
};
const onSelect = jest.fn();
describe('ScheduleListItem', () => {
let wrapper;
describe('User has edit permissions', () => {
beforeAll(() => {
wrapper = mountWithContexts(
<ScheduleListItem
isSelected={false}
onSelect={onSelect}
schedule={mockSchedule}
/>
);
});
afterAll(() => {
wrapper.unmount();
});
test('Name correctly shown with correct link', () => {
expect(
wrapper
.find('DataListCell')
.first()
.text()
).toBe('Mock Schedule');
expect(
wrapper
.find('DataListCell')
.first()
.find('Link')
.props().to
).toBe('/templates/job_template/12/schedules/6/details');
});
test('Type correctly shown', () => {
expect(
wrapper
.find('DataListCell')
.at(1)
.text()
).toBe('Playbook Run');
});
test('Edit button shown with correct link', () => {
expect(wrapper.find('PencilAltIcon').length).toBe(1);
expect(
wrapper
.find('Button')
.find('Link')
.props().to
).toBe('/templates/job_template/12/schedules/6/edit');
});
test('Toggle button enabled', () => {
expect(
wrapper
.find('Switch')
.first()
.props().isDisabled
).toBe(false);
});
test('Clicking checkbox makes expected callback', () => {
wrapper
.find('DataListCheck')
.first()
.find('input')
.simulate('change');
expect(onSelect).toHaveBeenCalledTimes(1);
});
});
describe('User has read-only permissions', () => {
beforeAll(() => {
wrapper = mountWithContexts(
<ScheduleListItem
isSelected={false}
onSelect={onSelect}
schedule={{
...mockSchedule,
summary_fields: {
...mockSchedule.summary_fields,
user_capabilities: {
edit: false,
delete: false,
},
},
}}
/>
);
});
afterAll(() => {
wrapper.unmount();
});
test('Name correctly shown with correct link', () => {
expect(
wrapper
.find('DataListCell')
.first()
.text()
).toBe('Mock Schedule');
expect(
wrapper
.find('DataListCell')
.first()
.find('Link')
.props().to
).toBe('/templates/job_template/12/schedules/6/details');
});
test('Type correctly shown', () => {
expect(
wrapper
.find('DataListCell')
.at(1)
.text()
).toBe('Playbook Run');
});
test('Edit button hidden', () => {
expect(wrapper.find('PencilAltIcon').length).toBe(0);
});
test('Toggle button disabled', () => {
expect(
wrapper
.find('Switch')
.first()
.props().isDisabled
).toBe(true);
});
});
});

View File

@@ -0,0 +1,2 @@
export { default as ScheduleList } from './ScheduleList';
export { default as ScheduleListItem } from './ScheduleListItem';

View File

@@ -1,26 +1,26 @@
import React, { Component, Fragment } from 'react';
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
class Schedules extends Component {
render() {
const { i18n } = this.props;
const { light } = PageSectionVariants;
import Breadcrumbs from '@components/Breadcrumbs';
import { ScheduleList } from './ScheduleList';
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">{i18n._(t`Schedules`)}</Title>
</PageSection>
<PageSection />
</Fragment>
);
}
function Schedules({ i18n }) {
return (
<>
<Breadcrumbs
breadcrumbConfig={{
'/schedules': i18n._(t`Schedules`),
}}
/>
<Switch>
<Route path="/schedules">
<ScheduleList />
</Route>
</Switch>
</>
);
}
export default withI18n()(Schedules);

View File

@@ -1,29 +1,36 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { createMemoryHistory } from 'history';
import Schedules from './Schedules';
describe('<Schedules />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<Schedules />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
});
let wrapper;
afterEach(() => {
pageWrapper.unmount();
wrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
expect(pageSections.first().props().variant).toBe('light');
test('initially renders succesfully', () => {
wrapper = mountWithContexts(<Schedules />);
});
test('should display schedule list breadcrumb heading', () => {
const history = createMemoryHistory({
initialEntries: ['/schedules'],
});
wrapper = mountWithContexts(<Schedules />, {
context: {
router: {
history,
route: {
location: history.location,
},
},
},
});
expect(wrapper.find('Crumb').length).toBe(1);
expect(wrapper.find('BreadcrumbHeading').text()).toBe('Schedules');
});
});

View File

@@ -0,0 +1,106 @@
{
"count": 5,
"next": "/api/v2/schedules/",
"previous": null,
"results": [
{
"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": 6,
"name": "Mock JT",
"description": "",
"unified_job_type": "job"
},
"user_capabilities": {
"edit": true,
"delete": true
}
},
"name": "Mock JT Schedule",
"next_run": "2020-02-20T05:00:00Z"
}, {
"url": "/api/v2/schedules/2",
"rrule":
"DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1",
"id": 2,
"summary_fields": {
"unified_job_template": {
"id": 7,
"name": "Mock WFJT",
"description": "",
"unified_job_type": "workflow_job"
},
"user_capabilities": {
"edit": true,
"delete": true
}
},
"name": "Mock WFJT Schedule",
"next_run": "2020-02-20T05:00:00Z"
}, {
"url": "/api/v2/schedules/3",
"rrule":
"DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1",
"id": 3,
"summary_fields": {
"unified_job_template": {
"id": 8,
"name": "Mock Project Update",
"description": "",
"unified_job_type": "project_update"
},
"user_capabilities": {
"edit": true,
"delete": true
}
},
"name": "Mock Project Update Schedule",
"next_run": "2020-02-20T05:00:00Z"
}, {
"url": "/api/v2/schedules/4",
"rrule":
"DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1",
"id": 4,
"summary_fields": {
"unified_job_template": {
"id": 9,
"name": "Mock System Job",
"description": "",
"unified_job_type": "system_job"
},
"user_capabilities": {
"edit": true,
"delete": true
}
},
"name": "Mock System Job Schedule",
"next_run": "2020-02-20T05:00:00Z"
}, {
"url": "/api/v2/schedules/5",
"rrule":
"DTSTART;TZID=America/New_York:20200220T000000 RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1",
"id": 5,
"summary_fields": {
"unified_job_template": {
"id": 10,
"name": "Mock Inventory Update",
"description": "",
"unified_job_type": "inventory_update"
},
"user_capabilities": {
"edit": true,
"delete": true
},
"inventory": {
"id": 11
}
},
"name": "Mock Inventory Update Schedule",
"next_run": "2020-02-20T05:00:00Z"
}
]
}

View File

@@ -0,0 +1,78 @@
import React, { Fragment, useState, useEffect, useCallback } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Switch, Tooltip } from '@patternfly/react-core';
import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import useRequest from '@util/useRequest';
import { SchedulesAPI } from '@api';
function ScheduleToggle({ schedule, onToggle, className, i18n }) {
const [isEnabled, setIsEnabled] = useState(schedule.enabled);
const [showError, setShowError] = useState(false);
const { result, isLoading, error, request: toggleSchedule } = useRequest(
useCallback(async () => {
await SchedulesAPI.update(schedule.id, {
enabled: !isEnabled,
});
return !isEnabled;
}, [schedule, isEnabled]),
schedule.enabled
);
useEffect(() => {
if (result !== isEnabled) {
setIsEnabled(result);
if (onToggle) {
onToggle(result);
}
}
}, [result, isEnabled, onToggle]);
useEffect(() => {
if (error) {
setShowError(true);
}
}, [error]);
return (
<Fragment>
<Tooltip
content={
schedule.enabled
? i18n._(t`Schedule is active`)
: i18n._(t`Schedule is inactive`)
}
position="top"
>
<Switch
className={className}
css="display: inline-flex;"
id={`schedule-${schedule.id}-toggle`}
label={i18n._(t`On`)}
labelOff={i18n._(t`Off`)}
isChecked={isEnabled}
isDisabled={
isLoading || !schedule.summary_fields.user_capabilities.edit
}
onChange={toggleSchedule}
aria-label={i18n._(t`Toggle schedule`)}
/>
</Tooltip>
{showError && error && !isLoading && (
<AlertModal
variant="error"
title={i18n._(t`Error!`)}
isOpen={error && !isLoading}
onClose={() => setShowError(false)}
>
{i18n._(t`Failed to toggle schedule.`)}
<ErrorDetail error={error} />
</AlertModal>
)}
</Fragment>
);
}
export default withI18n()(ScheduleToggle);

View File

@@ -0,0 +1,97 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { SchedulesAPI } from '@api';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import ScheduleToggle from './ScheduleToggle';
jest.mock('@api');
const mockSchedule = {
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: 6,
name: 'Mock JT',
description: '',
unified_job_type: 'job',
},
user_capabilities: {
edit: true,
delete: true,
},
},
name: 'Mock JT Schedule',
next_run: '2020-02-20T05:00:00Z',
enabled: true,
};
describe('<ScheduleToggle>', () => {
test('should should toggle off', async () => {
const onToggle = jest.fn();
const wrapper = mountWithContexts(
<ScheduleToggle schedule={mockSchedule} onToggle={onToggle} />
);
expect(wrapper.find('Switch').prop('isChecked')).toEqual(true);
await act(async () => {
wrapper.find('Switch').invoke('onChange')();
});
expect(SchedulesAPI.update).toHaveBeenCalledWith(1, {
enabled: false,
});
wrapper.update();
expect(wrapper.find('Switch').prop('isChecked')).toEqual(false);
expect(onToggle).toHaveBeenCalledWith(false);
});
test('should should toggle on', async () => {
const onToggle = jest.fn();
const wrapper = mountWithContexts(
<ScheduleToggle
schedule={{
...mockSchedule,
enabled: false,
}}
onToggle={onToggle}
/>
);
expect(wrapper.find('Switch').prop('isChecked')).toEqual(false);
await act(async () => {
wrapper.find('Switch').invoke('onChange')();
});
expect(SchedulesAPI.update).toHaveBeenCalledWith(1, {
enabled: true,
});
wrapper.update();
expect(wrapper.find('Switch').prop('isChecked')).toEqual(true);
expect(onToggle).toHaveBeenCalledWith(true);
});
test('should show error modal', async () => {
SchedulesAPI.update.mockImplementation(() => {
throw new Error('nope');
});
const wrapper = mountWithContexts(
<ScheduleToggle schedule={mockSchedule} />
);
expect(wrapper.find('Switch').prop('isChecked')).toEqual(true);
await act(async () => {
wrapper.find('Switch').invoke('onChange')();
});
wrapper.update();
const modal = wrapper.find('AlertModal');
expect(modal).toHaveLength(1);
expect(modal.prop('isOpen')).toEqual(true);
act(() => {
modal.invoke('onClose')();
});
wrapper.update();
expect(wrapper.find('AlertModal')).toHaveLength(0);
});
});

View File

@@ -271,3 +271,32 @@ export const SortColumns = arrayOf(
key: string.isRequired,
})
);
export const Schedule = shape({
rrule: string.isRequired,
id: number.isRequired,
type: string,
url: string,
related: shape({}),
summary_fields: shape({}),
created: string,
modified: string,
name: string.isRequired,
description: string,
extra_data: oneOfType([string, shape({})]),
inventory: number,
scm_branch: string,
job_type: string,
job_tags: string,
skip_tags: string,
limit: string,
diff_mode: bool,
verbosity: string,
unified_job_template: number,
enabled: bool,
dtstart: string,
dtend: string,
next_run: string,
timezone: string,
until: string,
});