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';