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,