diff --git a/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx b/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx index 69a84e7f79..f434ea40d5 100644 --- a/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx +++ b/awx/ui_next/src/components/DeleteButton/DeleteButton.jsx @@ -5,17 +5,24 @@ import { Button } from '@patternfly/react-core'; import AlertModal from '@components/AlertModal'; import { CardActionsRow } from '@components/Card'; -function DeleteButton({ onConfirm, modalTitle, name, i18n }) { +function DeleteButton({ + onConfirm, + modalTitle, + name, + i18n, + variant, + children, +}) { const [isOpen, setIsOpen] = useState(false); return ( <> )} - {job.name} + + + +

{job.name}

+
+ +
+ {deletionError && ( + this.setState({ deletionError: null })} + title={i18n._(t`Job Delete Error`)} + > + + + )} ); } } -export default JobOutput; +export { JobOutput as _JobOutput }; +export default withI18n()(withRouter(JobOutput)); diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.test.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.test.jsx index 50c0b71917..2d69b6cdd5 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.test.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; -import JobOutput from './JobOutput'; +import JobOutput, { _JobOutput } from './JobOutput'; import { JobsAPI } from '@api'; import mockJobData from '../shared/data.job.json'; import mockJobEventsData from './data.job_events.json'; @@ -60,7 +60,7 @@ describe('', () => { wrapper.unmount(); }); - test('initially renders succesfully', async done => { + test('initially renders succesfully', async () => { wrapper = mountWithContexts(); await waitForElement(wrapper, 'JobEvent', el => el.length > 0); await checkOutput(wrapper, [ @@ -119,12 +119,11 @@ describe('', () => { ]); expect(wrapper.find('JobOutput').length).toBe(1); - done(); }); - test('should call scrollToRow with expected index when scroll "previous" button is clicked', async done => { + test('should call scrollToRow with expected index when scroll "previous" button is clicked', async () => { const handleScrollPrevious = jest.spyOn( - JobOutput.prototype, + _JobOutput.prototype, 'handleScrollPrevious' ); wrapper = mountWithContexts(); @@ -140,12 +139,11 @@ describe('', () => { expect(handleScrollPrevious).toHaveBeenCalled(); expect(scrollMock).toHaveBeenCalledTimes(2); expect(scrollMock.mock.calls).toEqual([[100], [0]]); - done(); }); - test('should call scrollToRow with expected indices on when scroll "first" and "last" buttons are clicked', async done => { + test('should call scrollToRow with expected indices on when scroll "first" and "last" buttons are clicked', async () => { const handleScrollFirst = jest.spyOn( - JobOutput.prototype, + _JobOutput.prototype, 'handleScrollFirst' ); wrapper = mountWithContexts(); @@ -162,12 +160,11 @@ describe('', () => { expect(handleScrollFirst).toHaveBeenCalled(); expect(scrollMock).toHaveBeenCalledTimes(3); expect(scrollMock.mock.calls).toEqual([[0], [100], [0]]); - done(); }); - test('should call scrollToRow with expected index on when scroll "last" button is clicked', async done => { + test('should call scrollToRow with expected index on when scroll "last" button is clicked', async () => { const handleScrollLast = jest.spyOn( - JobOutput.prototype, + _JobOutput.prototype, 'handleScrollLast' ); wrapper = mountWithContexts(); @@ -184,13 +181,43 @@ describe('', () => { expect(handleScrollLast).toHaveBeenCalled(); expect(scrollMock).toHaveBeenCalledTimes(1); expect(scrollMock.mock.calls).toEqual([[100]]); - done(); }); - test('should throw error', async done => { + test('should make expected api call for delete', async () => { + wrapper = mountWithContexts(); + await waitForElement(wrapper, 'JobEvent', el => el.length > 0); + wrapper.find('button[aria-label="Delete"]').simulate('click'); + await waitForElement( + wrapper, + 'Modal', + el => el.props().isOpen === true && el.props().title === 'Delete Job' + ); + wrapper.find('Modal button[aria-label="Delete"]').simulate('click'); + expect(JobsAPI.destroy).toHaveBeenCalledTimes(1); + }); + + test('should show error dialog for failed deletion', async () => { + JobsAPI.destroy.mockRejectedValue(new Error({})); + wrapper = mountWithContexts(); + await waitForElement(wrapper, 'JobEvent', el => el.length > 0); + wrapper.find('button[aria-label="Delete"]').simulate('click'); + await waitForElement( + wrapper, + 'Modal', + el => el.props().isOpen === true && el.props().title === 'Delete Job' + ); + wrapper.find('Modal button[aria-label="Delete"]').simulate('click'); + await waitForElement(wrapper, 'Modal ErrorDetail'); + const errorModalCloseBtn = wrapper.find( + 'ModalBox div[aria-label="Job Delete Error"] button[aria-label="Close"]' + ); + errorModalCloseBtn.simulate('click'); + await waitForElement(wrapper, 'Modal ErrorDetail', el => el.length === 0); + }); + + test('should throw error', async () => { JobsAPI.readEvents = () => Promise.reject(new Error()); wrapper = mountWithContexts(); await waitForElement(wrapper, 'ContentError', el => el.length === 1); - done(); }); }); diff --git a/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx b/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx new file mode 100644 index 0000000000..d5b79749ae --- /dev/null +++ b/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx @@ -0,0 +1,172 @@ +import React from 'react'; +import styled from 'styled-components'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { shape, func } from 'prop-types'; +import { + DownloadIcon, + RocketIcon, + TrashAltIcon, +} from '@patternfly/react-icons'; +import { Badge as PFBadge, Button, Tooltip } from '@patternfly/react-core'; +import VerticalSeparator from '@components/VerticalSeparator'; +import DeleteButton from '@components/DeleteButton'; +import LaunchButton from '@components/LaunchButton'; + +const BadgeGroup = styled.div` + margin-left: 20px; + height: 18px; + display: inline-flex; +`; + +const Badge = styled(PFBadge)` + align-items: center; + display: flex; + justify-content: center; + margin-left: 10px; + ${props => + props.color + ? ` + background-color: ${props.color} + color: white; + ` + : null} +`; + +const Wrapper = styled.div` + align-items: center; + display: flex; + flex-flow: row wrap; + font-size: 14px; +`; + +const toHHMMSS = elapsed => { + const sec_num = parseInt(elapsed, 10); + const hours = Math.floor(sec_num / 3600); + const minutes = Math.floor(sec_num / 60) % 60; + const seconds = sec_num % 60; + + const stampHours = hours < 10 ? `0${hours}` : hours; + const stampMinutes = minutes < 10 ? `0${minutes}` : minutes; + const stampSeconds = seconds < 10 ? `0${seconds}` : seconds; + + return `${stampHours}:${stampMinutes}:${stampSeconds}`; +}; + +const OUTPUT_NO_COUNT_JOB_TYPES = [ + 'ad_hoc_command', + 'system_job', + 'inventory_update', +]; + +const OutputToolbar = ({ i18n, job, onDelete }) => { + const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type); + + const playCount = job?.playbook_counts?.play_count; + const taskCount = job?.playbook_counts?.task_count; + const darkCount = job?.host_status_counts?.dark; + const failureCount = job?.host_status_counts?.failures; + const totalHostCount = Object.keys(job?.host_status_counts || {}).reduce( + (sum, key) => sum + job?.host_status_counts[key], + 0 + ); + + return ( + + {!hideCounts && ( + <> + {playCount > 0 && ( + +
{i18n._(t`Plays`)}
+ {playCount} +
+ )} + {taskCount > 0 && ( + +
{i18n._(t`Tasks`)}
+ {taskCount} +
+ )} + {totalHostCount > 0 && ( + +
{i18n._(t`Hosts`)}
+ {totalHostCount} +
+ )} + {darkCount > 0 && ( + +
{i18n._(t`Unreachable`)}
+ + + {darkCount} + + +
+ )} + {failureCount > 0 && ( + +
{i18n._(t`Failed`)}
+ + + {failureCount} + + +
+ )} + + )} + + +
{i18n._(t`Elapsed`)}
+ + {toHHMMSS(job.elapsed)} + +
+ + + + {job.type !== 'system_job' && + job.summary_fields.user_capabilities?.start && ( + + + {({ handleRelaunch }) => ( + + )} + + + )} + + {job.related?.stdout && ( + + + + + + )} + + {job.summary_fields.user_capabilities.delete && ( + + + + + + )} +
+ ); +}; + +OutputToolbar.propTypes = { + job: shape({}).isRequired, + onDelete: func.isRequired, +}; + +export default withI18n()(OutputToolbar); diff --git a/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.test.jsx b/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.test.jsx new file mode 100644 index 0000000000..49b9c62678 --- /dev/null +++ b/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.test.jsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { OutputToolbar } from '.'; +import mockJobData from '../../shared/data.job.json'; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('initially renders without crashing', () => { + expect(wrapper.length).toBe(1); + }); + + test('should hide badge counts based on job type', () => { + wrapper = mountWithContexts( + {}} + /> + ); + expect(wrapper.find('div[aria-label="Play Count"]').length).toBe(0); + expect(wrapper.find('div[aria-label="Task Count"]').length).toBe(0); + expect(wrapper.find('div[aria-label="Host Count"]').length).toBe(0); + expect( + wrapper.find('div[aria-label="Unreachable Host Count"]').length + ).toBe(0); + expect(wrapper.find('div[aria-label="Failed Host Count"]').length).toBe(0); + expect(wrapper.find('div[aria-label="Elapsed Time"]').length).toBe(1); + }); + + test('should hide badge if count is equal to zero', () => { + wrapper = mountWithContexts( + {}} + /> + ); + + expect(wrapper.find('div[aria-label="Play Count"]').length).toBe(0); + expect(wrapper.find('div[aria-label="Task Count"]').length).toBe(0); + expect(wrapper.find('div[aria-label="Host Count"]').length).toBe(0); + expect( + wrapper.find('div[aria-label="Unreachable Host Count"]').length + ).toBe(0); + expect(wrapper.find('div[aria-label="Failed Host Count"]').length).toBe(0); + }); + + test('should display elapsed time as HH:MM:SS', () => { + wrapper = mountWithContexts( + {}} + /> + ); + + expect(wrapper.find('div[aria-label="Elapsed Time"] Badge').text()).toBe( + '76:11:05' + ); + }); + + test('should hide relaunch button based on user capabilities', () => { + expect(wrapper.find('LaunchButton').length).toBe(1); + wrapper = mountWithContexts( + {}} + /> + ); + expect(wrapper.find('LaunchButton').length).toBe(0); + }); + + test('should hide delete button based on user capabilities', () => { + expect(wrapper.find('DeleteButton').length).toBe(1); + wrapper = mountWithContexts( + {}} + /> + ); + expect(wrapper.find('DeleteButton').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/Job/JobOutput/shared/index.jsx b/awx/ui_next/src/screens/Job/JobOutput/shared/index.jsx index 58c72834e9..a93574639c 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/shared/index.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/shared/index.jsx @@ -1,5 +1,6 @@ +export { default as HostStatusBar } from './HostStatusBar'; export { default as JobEventLine } from './JobEventLine'; export { default as JobEventLineToggle } from './JobEventLineToggle'; export { default as JobEventLineNumber } from './JobEventLineNumber'; export { default as JobEventLineText } from './JobEventLineText'; -export { default as HostStatusBar } from './HostStatusBar'; +export { default as OutputToolbar } from './OutputToolbar';