diff --git a/awx/ui_next/src/components/JobList/JobListItem.jsx b/awx/ui_next/src/components/JobList/JobListItem.jsx
index a1cb09ad8ad6..27047015def7 100644
--- a/awx/ui_next/src/components/JobList/JobListItem.jsx
+++ b/awx/ui_next/src/components/JobList/JobListItem.jsx
@@ -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`)
+ }
>
-
- {({ handleRelaunch }) => (
-
- )}
-
+ {job.status === 'failed' && job.type === 'job' ? (
+
+ {({ handleRelaunch }) => (
+
+ )}
+
+ ) : (
+
+ {({ handleRelaunch }) => (
+
+ )}
+
+ )}
diff --git a/awx/ui_next/src/components/JobList/JobListItem.test.jsx b/awx/ui_next/src/components/JobList/JobListItem.test.jsx
index 6b9a9c3ae4f4..2e35f181a0ff 100644
--- a/awx/ui_next/src/components/JobList/JobListItem.test.jsx
+++ b/awx/ui_next/src/components/JobList/JobListItem.test.jsx
@@ -53,6 +53,10 @@ describe('', () => {
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(
@@ -92,3 +96,83 @@ describe('', () => {
expect(wrapper.find('Td[dataLabel="Type"]').length).toBe(1);
});
});
+
+describe('', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ const history = createMemoryHistory({
+ initialEntries: ['/jobs'],
+ });
+ wrapper = mountWithContexts(
+ ,
+ { 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(
+
+
+ {}}
+ isSelected
+ />
+
+
+ );
+ expect(wrapper.find('LaunchButton').length).toBe(1);
+ expect(wrapper.find('Dropdown')).toHaveLength(0);
+ });
+
+ test('launch button hidden from users without launch capabilities', () => {
+ wrapper = mountWithContexts(
+
+
+ {}}
+ isSelected={false}
+ />
+
+
+ );
+ expect(wrapper.find('LaunchButton').length).toBe(0);
+ });
+});
diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx
index a8322d86c537..17f7a4d0c1ba 100644
--- a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx
+++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx
@@ -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`);
diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx
index 4d4ce3ac2e9a..09af167081ce 100644
--- a/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx
+++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx
@@ -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');
});
diff --git a/awx/ui_next/src/components/LaunchButton/ReLaunchDropDown.jsx b/awx/ui_next/src/components/LaunchButton/ReLaunchDropDown.jsx
new file mode 100644
index 000000000000..dbd70d13abe1
--- /dev/null
+++ b/awx/ui_next/src/components/LaunchButton/ReLaunchDropDown.jsx
@@ -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 = [
+
+ {i18n._(t`Relaunch on`)}
+ ,
+ ,
+ {
+ handleRelaunch({ hosts: 'all' });
+ }}
+ >
+ {i18n._(t`All`)}
+ ,
+
+ {
+ handleRelaunch({ hosts: 'failed' });
+ }}
+ >
+ {i18n._(t`Failed hosts`)}
+ ,
+ ];
+
+ if (isPrimary) {
+ return (
+
+ {i18n._(t`Relaunch`)}
+
+ }
+ />
+ );
+ }
+
+ return (
+
+
+
+ }
+ />
+ );
+}
+
+export default withI18n()(ReLaunchDropDown);
diff --git a/awx/ui_next/src/components/LaunchButton/ReLaunchDropDown.test.jsx b/awx/ui_next/src/components/LaunchButton/ReLaunchDropDown.test.jsx
new file mode 100644
index 000000000000..dcd7d1e5ad01
--- /dev/null
+++ b/awx/ui_next/src/components/LaunchButton/ReLaunchDropDown.test.jsx
@@ -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(
+
+ );
+
+ expect(wrapper.find('Dropdown')).toHaveLength(1);
+ });
+
+ test('dropdown have expected items and callbacks', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ 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(
+
+ );
+ 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' });
+ });
+});
diff --git a/awx/ui_next/src/components/LaunchButton/index.js b/awx/ui_next/src/components/LaunchButton/index.js
index ed31194c0692..6cc13ea1414e 100644
--- a/awx/ui_next/src/components/LaunchButton/index.js
+++ b/awx/ui_next/src/components/LaunchButton/index.js
@@ -1 +1,2 @@
-export { default } from './LaunchButton';
+export { default as LaunchButton } from './LaunchButton';
+export { default as ReLaunchDropDown } from './ReLaunchDropDown';
diff --git a/awx/ui_next/src/components/TemplateList/TemplateListItem.jsx b/awx/ui_next/src/components/TemplateList/TemplateListItem.jsx
index 5638b8d123aa..a4ba4c360bec 100644
--- a/awx/ui_next/src/components/TemplateList/TemplateListItem.jsx
+++ b/awx/ui_next/src/components/TemplateList/TemplateListItem.jsx
@@ -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';
diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
index d41632008b26..b50b25ea23c6 100644
--- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
+++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
@@ -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 }) {
)}
{job.type !== 'system_job' &&
- job.summary_fields.user_capabilities.start && (
+ job.summary_fields.user_capabilities.start &&
+ (job.status === 'failed' && job.type === 'job' ? (
+
+ {({ handleRelaunch }) => (
+
+ )}
+
+ ) : (
{({ handleRelaunch }) => (
)}
- )}
+ ))}
{job.summary_fields.user_capabilities.delete && (
{
{job.type !== 'system_job' &&
job.summary_fields.user_capabilities?.start && (
-
-
- {({ handleRelaunch }) => (
-
- )}
-
+
+ {job.status === 'failed' && job.type === 'job' ? (
+
+ {({ handleRelaunch }) => (
+
+ )}
+
+ ) : (
+
+ {({ handleRelaunch }) => (
+
+ )}
+
+ )}
)}
diff --git a/awx/ui_next/src/screens/Project/ProjectJobTemplatesList/ProjectJobTemplatesListItem.jsx b/awx/ui_next/src/screens/Project/ProjectJobTemplatesList/ProjectJobTemplatesListItem.jsx
index df2fe6630fc5..2468ca83526d 100644
--- a/awx/ui_next/src/screens/Project/ProjectJobTemplatesList/ProjectJobTemplatesListItem.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectJobTemplatesList/ProjectJobTemplatesListItem.jsx
@@ -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';
diff --git a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx
index db3e45bc9162..3feed6bb7d69 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx
@@ -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';
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx
index 2a5242b9a0bd..0ce65ca09298 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx
@@ -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';
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx
index 1170858f83ce..f68453185a7d 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerToolbar.jsx
@@ -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,