Schedule list now uses useRequest hooks for fetching and deleting. Also rolled a component for schedule toggles that can be used throughout the tree.

This commit is contained in:
mabashian
2020-02-21 16:13:21 -05:00
parent e6e31a9fc6
commit e6f0c01aa6
5 changed files with 223 additions and 130 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -10,6 +10,7 @@ import DataListToolbar from '@components/DataListToolbar';
import PaginatedDataList, { import PaginatedDataList, {
ToolbarDeleteButton, ToolbarDeleteButton,
} from '@components/PaginatedDataList'; } from '@components/PaginatedDataList';
import useRequest, { useDeleteItems } from '@util/useRequest';
import { getQSConfig, parseQueryString } from '@util/qs'; import { getQSConfig, parseQueryString } from '@util/qs';
import { ScheduleListItem } from '.'; import { ScheduleListItem } from '.';
@@ -20,38 +21,54 @@ const QS_CONFIG = getQSConfig('schedule', {
}); });
function ScheduleList({ i18n }) { function ScheduleList({ i18n }) {
const [contentError, setContentError] = useState(null);
const [scheduleCount, setScheduleCount] = useState(0);
const [schedules, setSchedules] = useState([]);
const [deletionError, setDeletionError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(true);
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const [toggleError, setToggleError] = useState(null);
const [toggleLoading, setToggleLoading] = useState(null);
const location = useLocation(); const location = useLocation();
const loadSchedules = async ({ search }) => { const {
const params = parseQueryString(QS_CONFIG, search); result: { schedules, itemCount },
setContentError(null); error: contentError,
setHasContentLoading(true); isLoading,
try { request: fetchSchedules,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const { const {
data: { count, results }, data: { count, results },
} = await SchedulesAPI.read(params); } = await SchedulesAPI.read(params);
return {
setSchedules(results); itemCount: count,
setScheduleCount(count); schedules: results,
} catch (error) { };
setContentError(error); }, [location]),
} finally { {
setHasContentLoading(false); schedules: [],
itemCount: 0,
} }
}; );
useEffect(() => { useEffect(() => {
loadSchedules(location); fetchSchedules();
}, [location]); // eslint-disable-line react-hooks/exhaustive-deps }, [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 => { const handleSelectAll = isSelected => {
setSelected(isSelected ? [...schedules] : []); setSelected(isSelected ? [...schedules] : []);
@@ -66,64 +83,18 @@ function ScheduleList({ i18n }) {
}; };
const handleDelete = async () => { const handleDelete = async () => {
setHasContentLoading(true); await deleteJobs();
setSelected([]);
try {
await Promise.all(
selected.map(schedule => SchedulesAPI.destroy(schedule.id))
);
} catch (error) {
setDeletionError(error);
}
const params = parseQueryString(QS_CONFIG, location.search);
try {
const {
data: { count, results },
} = await SchedulesAPI.read(params);
setSchedules(results);
setScheduleCount(count);
setSelected([]);
} catch (error) {
setContentError(error);
}
setHasContentLoading(false);
}; };
const handleScheduleToggle = async scheduleToToggle => {
setToggleLoading(scheduleToToggle.id);
try {
const { data: updatedSchedule } = await SchedulesAPI.update(
scheduleToToggle.id,
{
enabled: !scheduleToToggle.enabled,
}
);
setSchedules(
schedules.map(schedule =>
schedule.id === updatedSchedule.id ? updatedSchedule : schedule
)
);
} catch (err) {
setToggleError(err);
} finally {
setToggleLoading(null);
}
};
const isAllSelected =
selected.length > 0 && selected.length === schedules.length;
return ( return (
<PageSection> <PageSection>
<Card> <Card>
<PaginatedDataList <PaginatedDataList
contentError={contentError} contentError={contentError}
hasContentLoading={hasContentLoading} hasContentLoading={isLoading || isDeleteLoading}
items={schedules} items={schedules}
itemCount={scheduleCount} itemCount={itemCount}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
onRowClick={handleSelect} onRowClick={handleSelect}
renderItem={item => ( renderItem={item => (
@@ -131,9 +102,7 @@ function ScheduleList({ i18n }) {
isSelected={selected.some(row => row.id === item.id)} isSelected={selected.some(row => row.id === item.id)}
key={item.id} key={item.id}
onSelect={() => handleSelect(item)} onSelect={() => handleSelect(item)}
onToggleSchedule={handleScheduleToggle}
schedule={item} schedule={item}
toggleLoading={toggleLoading === item.id}
/> />
)} )}
toolbarSearchColumns={[ toolbarSearchColumns={[
@@ -176,23 +145,12 @@ function ScheduleList({ i18n }) {
)} )}
/> />
</Card> </Card>
{toggleError && !toggleLoading && (
<AlertModal
variant="danger"
title={i18n._(t`Error!`)}
isOpen={toggleError && !toggleLoading}
onClose={() => setToggleError(null)}
>
{i18n._(t`Failed to toggle schedule.`)}
<ErrorDetail error={toggleError} />
</AlertModal>
)}
{deletionError && ( {deletionError && (
<AlertModal <AlertModal
isOpen={deletionError} isOpen={deletionError}
variant="danger" variant="danger"
title={i18n._(t`Error!`)} title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)} onClose={clearDeletionError}
> >
{i18n._(t`Failed to delete one or more schedules.`)} {i18n._(t`Failed to delete one or more schedules.`)}
<ErrorDetail error={deletionError} /> <ErrorDetail error={deletionError} />

View File

@@ -11,15 +11,14 @@ import {
DataListItem, DataListItem,
DataListItemRow, DataListItemRow,
DataListItemCells, DataListItemCells,
Switch,
Tooltip, Tooltip,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { PencilAltIcon } from '@patternfly/react-icons'; import { PencilAltIcon } from '@patternfly/react-icons';
import { DetailList, Detail } from '@components/DetailList'; import { DetailList, Detail } from '@components/DetailList';
import styled from 'styled-components'; import styled from 'styled-components';
import { Schedule } from '@types'; import { Schedule } from '@types';
import { formatDateString } from '@util/dates'; import { formatDateString } from '@util/dates';
import ScheduleToggle from '../shared/ScheduleToggle';
const DataListAction = styled(_DataListAction)` const DataListAction = styled(_DataListAction)`
align-items: center; align-items: center;
@@ -28,14 +27,7 @@ const DataListAction = styled(_DataListAction)`
grid-template-columns: auto 40px; grid-template-columns: auto 40px;
`; `;
function ScheduleListItem({ function ScheduleListItem({ i18n, isSelected, onSelect, schedule }) {
i18n,
isSelected,
onSelect,
onToggleSchedule,
schedule,
toggleLoading,
}) {
const labelId = `check-action-${schedule.id}`; const labelId = `check-action-${schedule.id}`;
const jobTypeLabels = { const jobTypeLabels = {
@@ -111,27 +103,7 @@ function ScheduleListItem({
id={labelId} id={labelId}
key="actions" key="actions"
> >
<Tooltip <ScheduleToggle schedule={schedule} />
content={
schedule.enabled
? i18n._(t`Schedule is active`)
: i18n._(t`Schedule is inactive`)
}
position="top"
>
<Switch
id={`schedule-${schedule.id}-toggle`}
label={i18n._(t`On`)}
labelOff={i18n._(t`Off`)}
isChecked={schedule.enabled}
isDisabled={
toggleLoading ||
!schedule.summary_fields.user_capabilities.edit
}
onChange={() => onToggleSchedule(schedule)}
aria-label={i18n._(t`Toggle schedule`)}
/>
</Tooltip>
{schedule.summary_fields.user_capabilities.edit && ( {schedule.summary_fields.user_capabilities.edit && (
<Tooltip content={i18n._(t`Edit Schedule`)} position="top"> <Tooltip content={i18n._(t`Edit Schedule`)} position="top">
<Button <Button
@@ -154,7 +126,6 @@ function ScheduleListItem({
ScheduleListItem.propTypes = { ScheduleListItem.propTypes = {
isSelected: bool.isRequired, isSelected: bool.isRequired,
onToggleSchedule: func.isRequired,
onSelect: func.isRequired, onSelect: func.isRequired,
schedule: Schedule.isRequired, schedule: Schedule.isRequired,
}; };

View File

@@ -43,7 +43,6 @@ const mockSchedule = {
until: '', until: '',
}; };
const onToggleSchedule = jest.fn();
const onSelect = jest.fn(); const onSelect = jest.fn();
describe('ScheduleListItem', () => { describe('ScheduleListItem', () => {
@@ -55,7 +54,6 @@ describe('ScheduleListItem', () => {
isSelected={false} isSelected={false}
onSelect={onSelect} onSelect={onSelect}
schedule={mockSchedule} schedule={mockSchedule}
onToggleSchedule={onToggleSchedule}
/> />
); );
}); });
@@ -102,14 +100,6 @@ describe('ScheduleListItem', () => {
.props().isDisabled .props().isDisabled
).toBe(false); ).toBe(false);
}); });
test('Clicking toggle makes expected callback', () => {
wrapper
.find('Switch')
.first()
.find('input')
.simulate('change');
expect(onToggleSchedule).toHaveBeenCalledWith(mockSchedule);
});
test('Clicking checkbox makes expected callback', () => { test('Clicking checkbox makes expected callback', () => {
wrapper wrapper
.find('DataListCheck') .find('DataListCheck')
@@ -135,7 +125,6 @@ describe('ScheduleListItem', () => {
}, },
}, },
}} }}
onToggleSchedule={onToggleSchedule}
/> />
); );
}); });

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);
});
});