From b00249b5154f362f3e48b152c6dee23d953a5c6f Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 7 Feb 2020 13:59:28 -0500 Subject: [PATCH 1/3] Add job event summary toolbar --- .../components/DeleteButton/DeleteButton.jsx | 13 +- .../VerticalSeparator/VerticalSeparator.jsx | 2 +- .../src/screens/Job/JobOutput/JobOutput.jsx | 86 ++++++++- .../screens/Job/JobOutput/JobOutput.test.jsx | 55 ++++-- .../Job/JobOutput/shared/OutputToolbar.jsx | 172 ++++++++++++++++++ .../JobOutput/shared/OutputToolbar.test.jsx | 105 +++++++++++ .../screens/Job/JobOutput/shared/index.jsx | 3 +- 7 files changed, 411 insertions(+), 25 deletions(-) create mode 100644 awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx create mode 100644 awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.test.jsx 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..3546fece0f --- /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; +`; + +function toHHMMSS(s) { + function pad(n) { + return `00${n}`.slice(-2); + } + + const secs = s % 60; + s = (s - secs) / 60; + const mins = s % 60; + const hrs = (s - mins) / 60; + + return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`; +} + +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..2e084e14c7 --- /dev/null +++ b/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.test.jsx @@ -0,0 +1,105 @@ +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 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'; From debbac5c7805d8959d2a9414e650f6196e52b820 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Mon, 10 Feb 2020 14:25:34 -0500 Subject: [PATCH 2/3] Use date object to format elapsed time --- .../src/screens/Job/JobOutput/JobOutput.jsx | 2 +- .../Job/JobOutput/shared/OutputToolbar.jsx | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx index e2f544c71e..76aed50c36 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx @@ -175,7 +175,7 @@ class JobOutput extends Component { try { switch (job.type) { case 'project_update': - await ProjectUpdatesAPI.destroy(job.idd); + await ProjectUpdatesAPI.destroy(job.id); break; case 'system_job': await SystemJobsAPI.destroy(job.id); diff --git a/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx b/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx index 3546fece0f..5248d539b7 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx @@ -40,18 +40,19 @@ const Wrapper = styled.div` font-size: 14px; `; -function toHHMMSS(s) { - function pad(n) { +const toHHMMSS = elapsed => { + const pad = n => { return `00${n}`.slice(-2); - } + }; - const secs = s % 60; - s = (s - secs) / 60; - const mins = s % 60; - const hrs = (s - mins) / 60; + const date = new Date(); + date.setTime(elapsed * 1000); + const hrs = date.getUTCHours(); + const mins = date.getUTCMinutes(); + const secs = date.getUTCSeconds(); return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`; -} +}; const OUTPUT_NO_COUNT_JOB_TYPES = [ 'ad_hoc_command', From 69049a44279c21f87e59941e0cad75cc32069f7b Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Mon, 10 Feb 2020 16:55:33 -0500 Subject: [PATCH 3/3] Convert elapsed days into hours and add unit test --- .../Job/JobOutput/shared/OutputToolbar.jsx | 19 +++++++++---------- .../JobOutput/shared/OutputToolbar.test.jsx | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx b/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx index 5248d539b7..d5b79749ae 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.jsx @@ -41,17 +41,16 @@ const Wrapper = styled.div` `; const toHHMMSS = elapsed => { - const pad = n => { - return `00${n}`.slice(-2); - }; + 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 date = new Date(); - date.setTime(elapsed * 1000); - const hrs = date.getUTCHours(); - const mins = date.getUTCMinutes(); - const secs = date.getUTCSeconds(); + const stampHours = hours < 10 ? `0${hours}` : hours; + const stampMinutes = minutes < 10 ? `0${minutes}` : minutes; + const stampSeconds = seconds < 10 ? `0${seconds}` : seconds; - return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`; + return `${stampHours}:${stampMinutes}:${stampSeconds}`; }; const OUTPUT_NO_COUNT_JOB_TYPES = [ @@ -67,7 +66,7 @@ const OutputToolbar = ({ i18n, job, onDelete }) => { 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( + const totalHostCount = Object.keys(job?.host_status_counts || {}).reduce( (sum, key) => sum + job?.host_status_counts[key], 0 ); 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 index 2e084e14c7..49b9c62678 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.test.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/shared/OutputToolbar.test.jsx @@ -67,6 +67,22 @@ describe('', () => { 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(