Merge pull request #9225 from nixocio/ui_issue_8670

Add relaunch against failed hosts

Reviewed-by: Mat Wilson <mawilson@redhat.com>
             https://github.com/one-t
This commit is contained in:
softwarefactory-project-zuul[bot] 2021-02-16 00:18:07 +00:00 committed by GitHub
commit 56868dbedd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 313 additions and 38 deletions

View File

@ -7,7 +7,7 @@ import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
import { RocketIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { ActionsTd, ActionItem } from '../PaginatedTable';
import LaunchButton from '../LaunchButton';
import { LaunchButton, ReLaunchDropDown } from '../LaunchButton';
import StatusLabel from '../StatusLabel';
import { DetailList, Detail, LaunchedByDetail } from '../DetailList';
import ChipGroup from '../ChipGroup';
@ -83,19 +83,31 @@ function JobListItem({
job.type !== 'system_job' &&
job.summary_fields?.user_capabilities?.start
}
tooltip={i18n._(t`Relaunch Job`)}
tooltip={
job.status === 'failed' && job.type === 'job'
? i18n._(t`Relaunch using host parameters`)
: i18n._(t`Relaunch Job`)
}
>
<LaunchButton resource={job}>
{({ handleRelaunch }) => (
<Button
variant="plain"
onClick={handleRelaunch}
aria-label={i18n._(t`Relaunch`)}
>
<RocketIcon />
</Button>
)}
</LaunchButton>
{job.status === 'failed' && job.type === 'job' ? (
<LaunchButton resource={job}>
{({ handleRelaunch }) => (
<ReLaunchDropDown handleRelaunch={handleRelaunch} />
)}
</LaunchButton>
) : (
<LaunchButton resource={job}>
{({ handleRelaunch }) => (
<Button
variant="plain"
onClick={handleRelaunch}
aria-label={i18n._(t`Relaunch`)}
>
<RocketIcon />
</Button>
)}
</LaunchButton>
)}
</ActionItem>
</ActionsTd>
</Tr>

View File

@ -53,6 +53,10 @@ describe('<JobListItem />', () => {
expect(wrapper.find('LaunchButton').length).toBe(1);
});
test('launch button shown to users with launch capabilities', () => {
expect(wrapper.find('LaunchButton').length).toBe(1);
});
test('launch button hidden from users without launch capabilities', () => {
wrapper = mountWithContexts(
<table>
@ -92,3 +96,83 @@ describe('<JobListItem />', () => {
expect(wrapper.find('Td[dataLabel="Type"]').length).toBe(1);
});
});
describe('<JobListItem with failed job />', () => {
let wrapper;
beforeEach(() => {
const history = createMemoryHistory({
initialEntries: ['/jobs'],
});
wrapper = mountWithContexts(
<table>
<tbody>
<JobListItem
job={{ ...mockJob, status: 'failed' }}
isSelected
onSelect={() => {}}
/>
</tbody>
</table>,
{ context: { router: { history } } }
);
});
afterEach(() => {
wrapper.unmount();
});
test('launch button shown to users with launch capabilities', () => {
expect(wrapper.find('LaunchButton').length).toBe(1);
});
test('dropdown should be displayed in case of failed job', () => {
expect(wrapper.find('LaunchButton').length).toBe(1);
const dropdown = wrapper.find('Dropdown');
expect(dropdown).toHaveLength(1);
expect(dropdown.find('DropdownItem')).toHaveLength(0);
dropdown.find('button').simulate('click');
wrapper.update();
expect(wrapper.find('DropdownItem')).toHaveLength(3);
});
test('dropdown should not be rendered for job type different of playbook run', () => {
wrapper = mountWithContexts(
<table>
<tbody>
<JobListItem
job={{
...mockJob,
status: 'failed',
type: 'project_update',
}}
onSelect={() => {}}
isSelected
/>
</tbody>
</table>
);
expect(wrapper.find('LaunchButton').length).toBe(1);
expect(wrapper.find('Dropdown')).toHaveLength(0);
});
test('launch button hidden from users without launch capabilities', () => {
wrapper = mountWithContexts(
<table>
<tbody>
<JobListItem
job={{
...mockJob,
status: 'failed',
summary_fields: { user_capabilities: { start: false } },
}}
detailUrl={`/jobs/playbook/${mockJob.id}`}
onSelect={() => {}}
isSelected={false}
/>
</tbody>
</table>
);
expect(wrapper.find('LaunchButton').length).toBe(0);
});
});

View File

@ -87,7 +87,7 @@ function LaunchButton({ resource, i18n, children, history }) {
}
};
const handleRelaunch = async () => {
const handleRelaunch = async params => {
let readRelaunch;
let relaunch;
@ -125,7 +125,7 @@ function LaunchButton({ resource, i18n, children, history }) {
} else if (resource.type === 'ad_hoc_command') {
relaunch = AdHocCommandsAPI.relaunch(resource.id);
} else if (resource.type === 'job') {
relaunch = JobsAPI.relaunch(resource.id);
relaunch = JobsAPI.relaunch(resource.id, params || {});
}
const { data: job } = await relaunch;
history.push(`/jobs/${job.id}/output`);

View File

@ -147,7 +147,7 @@ describe('LaunchButton', () => {
await act(() => button.prop('onClick')());
expect(JobsAPI.readRelaunch).toHaveBeenCalledWith(1);
await sleep(0);
expect(JobsAPI.relaunch).toHaveBeenCalledWith(1);
expect(JobsAPI.relaunch).toHaveBeenCalledWith(1, {});
expect(history.location.pathname).toEqual('/jobs/9000/output');
});

View File

@ -0,0 +1,96 @@
import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Dropdown,
DropdownToggle,
DropdownItem,
DropdownPosition,
DropdownSeparator,
DropdownDirection,
} from '@patternfly/react-core';
import { RocketIcon } from '@patternfly/react-icons';
function ReLaunchDropDown({ isPrimary = false, handleRelaunch, i18n }) {
const [isOpen, setIsOPen] = useState(false);
const onToggle = () => {
setIsOPen(prev => !prev);
};
const dropdownItems = [
<DropdownItem
aria-label={i18n._(t`Relaunch on`)}
key="relaunch_on"
component="div"
isPlainText
>
{i18n._(t`Relaunch on`)}
</DropdownItem>,
<DropdownSeparator key="separator" />,
<DropdownItem
key="relaunch_all"
aria-label={i18n._(t`Relaunch all hosts`)}
component="button"
onClick={() => {
handleRelaunch({ hosts: 'all' });
}}
>
{i18n._(t`All`)}
</DropdownItem>,
<DropdownItem
key="relaunch_failed"
aria-label={i18n._(t`Relaunch failed hosts`)}
component="button"
onClick={() => {
handleRelaunch({ hosts: 'failed' });
}}
>
{i18n._(t`Failed hosts`)}
</DropdownItem>,
];
if (isPrimary) {
return (
<Dropdown
position={DropdownPosition.left}
direction={DropdownDirection.up}
isOpen={isOpen}
dropdownItems={dropdownItems}
toggle={
<DropdownToggle
toggleIndicator={null}
onToggle={onToggle}
aria-label={i18n._(`relaunch jobs`)}
id="relaunch_jobs"
isPrimary
>
{i18n._(t`Relaunch`)}
</DropdownToggle>
}
/>
);
}
return (
<Dropdown
isPlain
position={DropdownPosition.right}
isOpen={isOpen}
dropdownItems={dropdownItems}
toggle={
<DropdownToggle
toggleIndicator={null}
onToggle={onToggle}
aria-label={i18n._(`relaunch jobs`)}
id="relaunch_jobs"
>
<RocketIcon />
</DropdownToggle>
}
/>
);
}
export default withI18n()(ReLaunchDropDown);

View File

@ -0,0 +1,55 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ReLaunchDropDown from './ReLaunchDropDown';
describe('ReLaunchDropDown', () => {
const handleRelaunch = jest.fn();
test('expected content is rendered on initialization', () => {
const wrapper = mountWithContexts(
<ReLaunchDropDown handleRelaunch={handleRelaunch} />
);
expect(wrapper.find('Dropdown')).toHaveLength(1);
});
test('dropdown have expected items and callbacks', () => {
const wrapper = mountWithContexts(
<ReLaunchDropDown handleRelaunch={handleRelaunch} />
);
expect(wrapper.find('DropdownItem')).toHaveLength(0);
wrapper.find('button').simulate('click');
wrapper.update();
expect(wrapper.find('DropdownItem')).toHaveLength(3);
wrapper
.find('DropdownItem[aria-label="Relaunch failed hosts"]')
.simulate('click');
expect(handleRelaunch).toHaveBeenCalledWith({ hosts: 'failed' });
wrapper
.find('DropdownItem[aria-label="Relaunch all hosts"]')
.simulate('click');
expect(handleRelaunch).toHaveBeenCalledWith({ hosts: 'all' });
});
test('dropdown isPrimary have expected items and callbacks', () => {
const wrapper = mountWithContexts(
<ReLaunchDropDown isPrimary handleRelaunch={handleRelaunch} />
);
expect(wrapper.find('DropdownItem')).toHaveLength(0);
wrapper.find('button').simulate('click');
wrapper.update();
expect(wrapper.find('DropdownItem')).toHaveLength(3);
wrapper
.find('DropdownItem[aria-label="Relaunch failed hosts"]')
.simulate('click');
expect(handleRelaunch).toHaveBeenCalledWith({ hosts: 'failed' });
wrapper
.find('DropdownItem[aria-label="Relaunch all hosts"]')
.simulate('click');
expect(handleRelaunch).toHaveBeenCalledWith({ hosts: 'all' });
});
});

View File

@ -1 +1,2 @@
export { default } from './LaunchButton';
export { default as LaunchButton } from './LaunchButton';
export { default as ReLaunchDropDown } from './ReLaunchDropDown';

View File

@ -18,7 +18,7 @@ import CredentialChip from '../CredentialChip';
import { timeOfDay, formatDateString } from '../../util/dates';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api';
import LaunchButton from '../LaunchButton';
import { LaunchButton } from '../LaunchButton';
import Sparkline from '../Sparkline';
import { toTitleCase } from '../../util/strings';
import CopyButton from '../CopyButton';

View File

@ -19,7 +19,10 @@ import CredentialChip from '../../../components/CredentialChip';
import { VariablesInput as _VariablesInput } from '../../../components/CodeMirrorInput';
import DeleteButton from '../../../components/DeleteButton';
import ErrorDetail from '../../../components/ErrorDetail';
import LaunchButton from '../../../components/LaunchButton';
import {
LaunchButton,
ReLaunchDropDown,
} from '../../../components/LaunchButton';
import StatusIcon from '../../../components/StatusIcon';
import { toTitleCase } from '../../../util/strings';
import { formatDateString } from '../../../util/dates';
@ -346,7 +349,14 @@ function JobDetail({ job, i18n }) {
)}
<CardActionsRow>
{job.type !== 'system_job' &&
job.summary_fields.user_capabilities.start && (
job.summary_fields.user_capabilities.start &&
(job.status === 'failed' && job.type === 'job' ? (
<LaunchButton resource={job}>
{({ handleRelaunch }) => (
<ReLaunchDropDown isPrimary handleRelaunch={handleRelaunch} />
)}
</LaunchButton>
) : (
<LaunchButton resource={job} aria-label={i18n._(t`Relaunch`)}>
{({ handleRelaunch }) => (
<Button type="submit" onClick={handleRelaunch}>
@ -354,7 +364,7 @@ function JobDetail({ job, i18n }) {
</Button>
)}
</LaunchButton>
)}
))}
{job.summary_fields.user_capabilities.delete && (
<DeleteButton
name={job.name}

View File

@ -11,7 +11,10 @@ import {
} from '@patternfly/react-icons';
import { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core';
import DeleteButton from '../../../../components/DeleteButton';
import LaunchButton from '../../../../components/LaunchButton';
import {
LaunchButton,
ReLaunchDropDown,
} from '../../../../components/LaunchButton';
const BadgeGroup = styled.div`
margin-left: 20px;
@ -125,18 +128,32 @@ const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => {
{job.type !== 'system_job' &&
job.summary_fields.user_capabilities?.start && (
<Tooltip content={i18n._(t`Relaunch Job`)}>
<LaunchButton resource={job} aria-label={i18n._(t`Relaunch`)}>
{({ handleRelaunch }) => (
<Button
variant="plain"
onClick={handleRelaunch}
aria-label={i18n._(t`Relaunch`)}
>
<RocketIcon />
</Button>
)}
</LaunchButton>
<Tooltip
content={
job.status === 'failed' && job.type === 'job'
? i18n._(t`Relaunch using host parameters`)
: i18n._(t`Relaunch Job`)
}
>
{job.status === 'failed' && job.type === 'job' ? (
<LaunchButton resource={job}>
{({ handleRelaunch }) => (
<ReLaunchDropDown handleRelaunch={handleRelaunch} />
)}
</LaunchButton>
) : (
<LaunchButton resource={job}>
{({ handleRelaunch }) => (
<Button
variant="plain"
onClick={handleRelaunch}
aria-label={i18n._(t`Relaunch`)}
>
<RocketIcon />
</Button>
)}
</LaunchButton>
)}
</Tooltip>
)}

View File

@ -20,7 +20,7 @@ import {
import styled from 'styled-components';
import DataListCell from '../../../components/DataListCell';
import LaunchButton from '../../../components/LaunchButton';
import { LaunchButton } from '../../../components/LaunchButton';
import Sparkline from '../../../components/Sparkline';
import { toTitleCase } from '../../../util/strings';

View File

@ -26,7 +26,7 @@ import {
} from '../../../components/DetailList';
import DeleteButton from '../../../components/DeleteButton';
import ErrorDetail from '../../../components/ErrorDetail';
import LaunchButton from '../../../components/LaunchButton';
import { LaunchButton } from '../../../components/LaunchButton';
import { VariablesDetail } from '../../../components/CodeMirrorInput';
import { JobTemplatesAPI } from '../../../api';
import useRequest, { useDismissableError } from '../../../util/useRequest';

View File

@ -24,7 +24,7 @@ import {
UserDateDetail,
} from '../../../components/DetailList';
import ErrorDetail from '../../../components/ErrorDetail';
import LaunchButton from '../../../components/LaunchButton';
import { LaunchButton } from '../../../components/LaunchButton';
import Sparkline from '../../../components/Sparkline';
import { toTitleCase } from '../../../util/strings';
import useRequest, { useDismissableError } from '../../../util/useRequest';

View File

@ -18,7 +18,7 @@ import {
WrenchIcon,
} from '@patternfly/react-icons';
import styled from 'styled-components';
import LaunchButton from '../../../components/LaunchButton';
import { LaunchButton } from '../../../components/LaunchButton';
import {
WorkflowDispatchContext,
WorkflowStateContext,