diff --git a/CHANGELOG.md b/CHANGELOG.md index 61b4a8b43d..827299ffd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ To learn more about Ansible Builder and Execution Environments, see: https://www - Added user interface for management jobs: https://github.com/ansible/awx/pull/9224 - Added toast message to show notification template test result to notification templates list https://github.com/ansible/awx/pull/9318 - Replaced CodeMirror with AceEditor for editing template variables and notification templates https://github.com/ansible/awx/pull/9281 +- Added support for filtering and pagination on job output https://github.com/ansible/awx/pull/9208 # 17.1.0 (March 9th, 2021) - Addressed a security issue in AWX (CVE-2021-20253) diff --git a/awx/ui_next/src/api/models/Jobs.js b/awx/ui_next/src/api/models/Jobs.js index db28e172b6..026ae671f0 100644 --- a/awx/ui_next/src/api/models/Jobs.js +++ b/awx/ui_next/src/api/models/Jobs.js @@ -53,6 +53,16 @@ class Jobs extends RelaunchMixin(Base) { } return this.http.get(endpoint, { params }); } + + readEventOptions(id, type = 'playbook') { + let endpoint; + if (type === 'playbook') { + endpoint = `/api/v2${getBaseURL(type)}${id}/job_events/`; + } else { + endpoint = `/api/v2${getBaseURL(type)}${id}/events/`; + } + return this.http.options(endpoint); + } } export default Jobs; diff --git a/awx/ui_next/src/components/JobList/JobList.jsx b/awx/ui_next/src/components/JobList/JobList.jsx index 30804d4b7f..5c331fd8a5 100644 --- a/awx/ui_next/src/components/JobList/JobList.jsx +++ b/awx/ui_next/src/components/JobList/JobList.jsx @@ -13,6 +13,7 @@ import useRequest, { useDeleteItems, useDismissableError, } from '../../util/useRequest'; +import isJobRunning from '../../util/jobs'; import { getQSConfig, parseQueryString } from '../../util/qs'; import JobListItem from './JobListItem'; import JobListCancelButton from './JobListCancelButton'; @@ -102,7 +103,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { useCallback(async () => { return Promise.all( selected.map(job => { - if (['new', 'pending', 'waiting', 'running'].includes(job.status)) { + if (isJobRunning(job.status)) { return JobsAPI.cancel(job.id, job.type); } return Promise.resolve(); diff --git a/awx/ui_next/src/components/JobList/JobListCancelButton.jsx b/awx/ui_next/src/components/JobList/JobListCancelButton.jsx index ff4cf1cda6..5564464f79 100644 --- a/awx/ui_next/src/components/JobList/JobListCancelButton.jsx +++ b/awx/ui_next/src/components/JobList/JobListCancelButton.jsx @@ -4,18 +4,18 @@ import { t } from '@lingui/macro'; import { arrayOf, func } from 'prop-types'; import { Button, DropdownItem, Tooltip } from '@patternfly/react-core'; import { KebabifiedContext } from '../../contexts/Kebabified'; +import isJobRunning from '../../util/jobs'; import AlertModal from '../AlertModal'; import { Job } from '../../types'; function cannotCancelBecausePermissions(job) { return ( - !job.summary_fields.user_capabilities.start && - ['pending', 'waiting', 'running'].includes(job.status) + !job.summary_fields.user_capabilities.start && isJobRunning(job.status) ); } function cannotCancelBecauseNotRunning(job) { - return !['pending', 'waiting', 'running'].includes(job.status); + return !isJobRunning(job.status); } function JobListCancelButton({ i18n, jobsToCancel, onCancel }) { diff --git a/awx/ui_next/src/components/LaunchButton/ReLaunchDropDown.jsx b/awx/ui_next/src/components/LaunchButton/ReLaunchDropDown.jsx index 709d31e06c..6d0539c705 100644 --- a/awx/ui_next/src/components/LaunchButton/ReLaunchDropDown.jsx +++ b/awx/ui_next/src/components/LaunchButton/ReLaunchDropDown.jsx @@ -11,7 +11,7 @@ import { } from '@patternfly/react-core'; import { RocketIcon } from '@patternfly/react-icons'; -function ReLaunchDropDown({ isPrimary = false, handleRelaunch, i18n }) { +function ReLaunchDropDown({ isPrimary = false, handleRelaunch, i18n, ouiaId }) { const [isOpen, setIsOPen] = useState(false); const onToggle = () => { @@ -75,6 +75,7 @@ function ReLaunchDropDown({ isPrimary = false, handleRelaunch, i18n }) { return ( {allKeys.map(optionKey => ( @@ -149,7 +150,7 @@ function AdvancedSearch({ selections={lookupSelection} isOpen={isLookupDropdownOpen} placeholderText={i18n._(t`Lookup type`)} - maxHeight="500px" + maxHeight={maxSelectHeight} noResultsFoundText={i18n._(t`No results found`)} > {searchOptions} @@ -201,6 +204,7 @@ function Search({ onSearch={onSearch} searchableKeys={searchableKeys} relatedSearchableKeys={relatedSearchableKeys} + maxSelectHeight={maxSelectHeight} /> )) || (options && ( @@ -219,6 +223,8 @@ function Search({ isOpen={isFilterDropdownOpen} placeholderText={`Filter By ${name}`} ouiaId={`filter-by-${key}`} + isDisabled={isDisabled} + maxHeight={maxSelectHeight} > {options.map(([optionKey, optionLabel]) => ( {booleanLabels.true || i18n._(t`Yes`)} @@ -265,11 +273,12 @@ function Search({ value={searchValue} onChange={setSearchValue} onKeyDown={handleTextKeyDown} + isDisabled={isDisabled} />
, - , - ]} + isDisabled={isCancelling} + aria-label={i18n._(t`Cancel job`)} + onClick={cancelJob} > - {i18n._( - t`Are you sure you want to submit the request to cancel this job?` - )} - + {i18n._(t`Cancel job`)} + , + , + ]} + > + {i18n._( + t`Are you sure you want to submit the request to cancel this job?` )} - + )} - {cancelError && ( - - {({ i18n }) => ( - this.setState({ cancelError: null })} - title={i18n._(t`Job Cancel Error`)} - label={i18n._(t`Job Cancel Error`)} - > - - - )} - - )} - {deletionError && ( - - {({ i18n }) => ( - this.setState({ deletionError: null })} - title={i18n._(t`Job Delete Error`)} - label={i18n._(t`Job Delete Error`)} - > - - - )} - - )} - - ); - } + {dismissableDeleteError && ( + + + + )} + {dismissableCancelError && ( + + + + )} + + )} + + ); } export { JobOutput as _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 5531a0ade6..ef2fa35190 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.test.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.test.jsx @@ -1,15 +1,43 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts, waitForElement, } from '../../../../testUtils/enzymeHelpers'; -import JobOutput, { _JobOutput } from './JobOutput'; +import JobOutput from './JobOutput'; import { JobsAPI } from '../../../api'; import mockJobData from '../shared/data.job.json'; import mockJobEventsData from './data.job_events.json'; +import mockFilteredJobEventsData from './data.filtered_job_events.json'; jest.mock('../../../api'); +const generateChattyRows = () => { + const rows = [ + '', + 'PLAY [all] *********************************************************************16:17:13', + '', + 'TASK [debug] *******************************************************************16:17:13', + ]; + + for (let i = 1; i < 95; i++) { + rows.push( + `ok: [localhost] => (item=${i}) => {`, + ` "msg": "This is a debug message: ${i}"`, + '}' + ); + } + + rows.push( + '', + 'PLAY RECAP *********************************************************************16:17:15', + 'localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 ', + '' + ); + + return rows; +}; + async function checkOutput(wrapper, expectedLines) { await waitForElement(wrapper, 'div[type="job_event"]', el => el.length > 1); const jobEventLines = wrapper.find('JobEventLineText div'); @@ -41,11 +69,19 @@ async function findScrollButtons(wrapper) { }; } +const originalOffsetHeight = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'offsetHeight' +); +const originalOffsetWidth = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'offsetWidth' +); + describe('', () => { let wrapper; const mockJob = mockJobData; const mockJobEvents = mockJobEventsData; - const scrollMock = jest.fn(); beforeEach(() => { JobsAPI.readEvents.mockResolvedValue({ @@ -64,289 +100,194 @@ describe('', () => { }); test('initially renders succesfully', async () => { - wrapper = mountWithContexts(); + await act(async () => { + wrapper = mountWithContexts(); + }); await waitForElement(wrapper, 'JobEvent', el => el.length > 0); - await checkOutput(wrapper, [ - 'ok: [localhost] => (item=37) => {', - ' "msg": "This is a debug message: 37"', - '}', - 'ok: [localhost] => (item=38) => {', - ' "msg": "This is a debug message: 38"', - '}', - 'ok: [localhost] => (item=39) => {', - ' "msg": "This is a debug message: 39"', - '}', - 'ok: [localhost] => (item=40) => {', - ' "msg": "This is a debug message: 40"', - '}', - 'ok: [localhost] => (item=41) => {', - ' "msg": "This is a debug message: 41"', - '}', - 'ok: [localhost] => (item=42) => {', - ' "msg": "This is a debug message: 42"', - '}', - 'ok: [localhost] => (item=43) => {', - ' "msg": "This is a debug message: 43"', - '}', - 'ok: [localhost] => (item=44) => {', - ' "msg": "This is a debug message: 44"', - '}', - 'ok: [localhost] => (item=45) => {', - ' "msg": "This is a debug message: 45"', - '}', - 'ok: [localhost] => (item=46) => {', - ' "msg": "This is a debug message: 46"', - '}', - 'ok: [localhost] => (item=47) => {', - ' "msg": "This is a debug message: 47"', - '}', - 'ok: [localhost] => (item=48) => {', - ' "msg": "This is a debug message: 48"', - '}', - 'ok: [localhost] => (item=49) => {', - ' "msg": "This is a debug message: 49"', - '}', - 'ok: [localhost] => (item=50) => {', - ' "msg": "This is a debug message: 50"', - '}', - 'ok: [localhost] => (item=51) => {', - ' "msg": "This is a debug message: 51"', - '}', - 'ok: [localhost] => (item=52) => {', - ' "msg": "This is a debug message: 52"', - '}', - 'ok: [localhost] => (item=53) => {', - ' "msg": "This is a debug message: 53"', - '}', - 'ok: [localhost] => (item=54) => {', - ' "msg": "This is a debug message: 54"', - '}', - 'ok: [localhost] => (item=55) => {', - ' "msg": "This is a debug message: 55"', - '}', - 'ok: [localhost] => (item=56) => {', - ' "msg": "This is a debug message: 56"', - '}', - 'ok: [localhost] => (item=57) => {', - ' "msg": "This is a debug message: 57"', - '}', - 'ok: [localhost] => (item=58) => {', - ' "msg": "This is a debug message: 58"', - '}', - 'ok: [localhost] => (item=59) => {', - ' "msg": "This is a debug message: 59"', - '}', - 'ok: [localhost] => (item=60) => {', - ' "msg": "This is a debug message: 60"', - '}', - 'ok: [localhost] => (item=61) => {', - ' "msg": "This is a debug message: 61"', - '}', - 'ok: [localhost] => (item=62) => {', - ' "msg": "This is a debug message: 62"', - '}', - 'ok: [localhost] => (item=63) => {', - ' "msg": "This is a debug message: 63"', - '}', - 'ok: [localhost] => (item=64) => {', - ' "msg": "This is a debug message: 64"', - '}', - 'ok: [localhost] => (item=65) => {', - ' "msg": "This is a debug message: 65"', - '}', - 'ok: [localhost] => (item=66) => {', - ' "msg": "This is a debug message: 66"', - '}', - 'ok: [localhost] => (item=67) => {', - ' "msg": "This is a debug message: 67"', - '}', - 'ok: [localhost] => (item=68) => {', - ' "msg": "This is a debug message: 68"', - '}', - 'ok: [localhost] => (item=69) => {', - ' "msg": "This is a debug message: 69"', - '}', - 'ok: [localhost] => (item=70) => {', - ' "msg": "This is a debug message: 70"', - '}', - 'ok: [localhost] => (item=71) => {', - ' "msg": "This is a debug message: 71"', - '}', - 'ok: [localhost] => (item=72) => {', - ' "msg": "This is a debug message: 72"', - '}', - 'ok: [localhost] => (item=73) => {', - ' "msg": "This is a debug message: 73"', - '}', - 'ok: [localhost] => (item=74) => {', - ' "msg": "This is a debug message: 74"', - '}', - 'ok: [localhost] => (item=75) => {', - ' "msg": "This is a debug message: 75"', - '}', - 'ok: [localhost] => (item=76) => {', - ' "msg": "This is a debug message: 76"', - '}', - 'ok: [localhost] => (item=77) => {', - ' "msg": "This is a debug message: 77"', - '}', - 'ok: [localhost] => (item=78) => {', - ' "msg": "This is a debug message: 78"', - '}', - 'ok: [localhost] => (item=79) => {', - ' "msg": "This is a debug message: 79"', - '}', - 'ok: [localhost] => (item=80) => {', - ' "msg": "This is a debug message: 80"', - '}', - 'ok: [localhost] => (item=81) => {', - ' "msg": "This is a debug message: 81"', - '}', - 'ok: [localhost] => (item=82) => {', - ' "msg": "This is a debug message: 82"', - '}', - 'ok: [localhost] => (item=83) => {', - ' "msg": "This is a debug message: 83"', - '}', - 'ok: [localhost] => (item=84) => {', - ' "msg": "This is a debug message: 84"', - '}', - 'ok: [localhost] => (item=85) => {', - ' "msg": "This is a debug message: 85"', - '}', - 'ok: [localhost] => (item=86) => {', - ' "msg": "This is a debug message: 86"', - '}', - 'ok: [localhost] => (item=87) => {', - ' "msg": "This is a debug message: 87"', - '}', - 'ok: [localhost] => (item=88) => {', - ' "msg": "This is a debug message: 88"', - '}', - 'ok: [localhost] => (item=89) => {', - ' "msg": "This is a debug message: 89"', - '}', - 'ok: [localhost] => (item=90) => {', - ' "msg": "This is a debug message: 90"', - '}', - 'ok: [localhost] => (item=91) => {', - ' "msg": "This is a debug message: 91"', - '}', - 'ok: [localhost] => (item=92) => {', - ' "msg": "This is a debug message: 92"', - '}', - 'ok: [localhost] => (item=93) => {', - ' "msg": "This is a debug message: 93"', - '}', - 'ok: [localhost] => (item=94) => {', - ' "msg": "This is a debug message: 94"', - '}', - '', - 'PLAY RECAP *********************************************************************15:37:26', - 'localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 ', - '', - ]); + + await checkOutput(wrapper, generateChattyRows()); expect(wrapper.find('JobOutput').length).toBe(1); }); - test('should call scrollToRow with expected index when scroll "previous" button is clicked', async () => { - const handleScrollPrevious = jest.spyOn( - _JobOutput.prototype, - 'handleScrollPrevious' - ); - wrapper = mountWithContexts(); + test('navigation buttons should display output properly', async () => { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 10, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 100, + }); + await act(async () => { + wrapper = mountWithContexts(); + }); await waitForElement(wrapper, 'JobEvent', el => el.length > 0); - const { scrollLastButton, scrollPreviousButton } = await findScrollButtons( - wrapper + const { + scrollFirstButton, + scrollLastButton, + scrollPreviousButton, + } = await findScrollButtons(wrapper); + let jobEvents = wrapper.find('JobEvent'); + expect(jobEvents.at(0).prop('stdout')).toBe(''); + expect(jobEvents.at(1).prop('stdout')).toBe( + '\r\nPLAY [all] *********************************************************************' ); - wrapper.find('JobOutput').instance().scrollToRow = scrollMock; - - scrollLastButton.simulate('click'); - scrollPreviousButton.simulate('click'); - - expect(handleScrollPrevious).toHaveBeenCalled(); - expect(scrollMock).toHaveBeenCalledTimes(2); - expect(scrollMock.mock.calls).toEqual([[100], [0]]); - }); - - test('should call scrollToRow with expected indices on when scroll "first" and "last" buttons are clicked', async () => { - const handleScrollFirst = jest.spyOn( - _JobOutput.prototype, - 'handleScrollFirst' + await act(async () => { + scrollLastButton.simulate('click'); + }); + wrapper.update(); + jobEvents = wrapper.find('JobEvent'); + expect(jobEvents.at(jobEvents.length - 2).prop('stdout')).toBe( + '\r\nPLAY RECAP *********************************************************************\r\n\u001b[0;32mlocalhost\u001b[0m : \u001b[0;32mok=1 \u001b[0m changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 \r\n' ); - wrapper = mountWithContexts(); - await waitForElement(wrapper, 'JobEvent', el => el.length > 0); - const { scrollFirstButton, scrollLastButton } = await findScrollButtons( - wrapper + expect(jobEvents.at(jobEvents.length - 1).prop('stdout')).toBe(''); + await act(async () => { + scrollPreviousButton.simulate('click'); + }); + wrapper.update(); + jobEvents = wrapper.find('JobEvent'); + expect(jobEvents.at(0).prop('stdout')).toBe( + '\u001b[0;32mok: [localhost] => (item=76) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 76"\u001b[0m\r\n\u001b[0;32m}\u001b[0m' ); - wrapper.find('JobOutput').instance().scrollToRow = scrollMock; - - scrollFirstButton.simulate('click'); - scrollLastButton.simulate('click'); - scrollFirstButton.simulate('click'); - - expect(handleScrollFirst).toHaveBeenCalled(); - expect(scrollMock).toHaveBeenCalledTimes(3); - expect(scrollMock.mock.calls).toEqual([[0], [100], [0]]); - }); - - test('should call scrollToRow with expected index on when scroll "last" button is clicked', async () => { - const handleScrollLast = jest.spyOn( - _JobOutput.prototype, - 'handleScrollLast' + expect(jobEvents.at(1).prop('stdout')).toBe( + '\u001b[0;32mok: [localhost] => (item=77) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 77"\u001b[0m\r\n\u001b[0;32m}\u001b[0m' + ); + await act(async () => { + scrollFirstButton.simulate('click'); + }); + wrapper.update(); + jobEvents = wrapper.find('JobEvent'); + expect(jobEvents.at(0).prop('stdout')).toBe(''); + expect(jobEvents.at(1).prop('stdout')).toBe( + '\r\nPLAY [all] *********************************************************************' + ); + await act(async () => { + scrollLastButton.simulate('click'); + }); + wrapper.update(); + jobEvents = wrapper.find('JobEvent'); + expect(jobEvents.at(jobEvents.length - 2).prop('stdout')).toBe( + '\r\nPLAY RECAP *********************************************************************\r\n\u001b[0;32mlocalhost\u001b[0m : \u001b[0;32mok=1 \u001b[0m changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 \r\n' + ); + expect(jobEvents.at(jobEvents.length - 1).prop('stdout')).toBe(''); + Object.defineProperty( + HTMLElement.prototype, + 'offsetHeight', + originalOffsetHeight + ); + Object.defineProperty( + HTMLElement.prototype, + 'offsetWidth', + originalOffsetWidth ); - wrapper = mountWithContexts(); - await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); - wrapper - .find('JobOutput') - .instance() - .handleResize({ width: 100 }); - const { scrollLastButton } = await findScrollButtons(wrapper); - wrapper.find('JobOutput').instance().scrollToRow = scrollMock; - - scrollLastButton.simulate('click'); - - expect(handleScrollLast).toHaveBeenCalled(); - expect(scrollMock).toHaveBeenCalledTimes(1); - expect(scrollMock.mock.calls).toEqual([[100]]); }); test('should make expected api call for delete', async () => { - wrapper = mountWithContexts(); + await act(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'); + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); expect(JobsAPI.destroy).toHaveBeenCalledTimes(1); }); test('should show error dialog for failed deletion', async () => { - JobsAPI.destroy.mockRejectedValue(new Error({})); - wrapper = mountWithContexts(); + JobsAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'delete', + url: `/api/v2/jobs/${mockJob.id}`, + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + await act(async () => { + wrapper = mountWithContexts(); + }); await waitForElement(wrapper, 'JobEvent', el => el.length > 0); - wrapper.find('button[aria-label="Delete"]').simulate('click'); + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); await waitForElement( wrapper, - 'Modal', - el => el.props().isOpen === true && el.props().title === 'Delete Job' + 'Modal[title="Job Delete Error"]', + el => el.length === 1 ); - wrapper.find('Modal button[aria-label="Delete"]').simulate('click'); - await waitForElement(wrapper, 'Modal ErrorDetail'); - const errorModalCloseBtn = wrapper.find( - 'ModalBox[aria-label="Job Delete Error"] ModalBoxCloseButton' + await act(async () => { + wrapper.find('Modal[title="Job Delete Error"]').invoke('onClose')(); + }); + await waitForElement( + wrapper, + 'Modal[title="Job Delete Error"]', + el => el.length === 0 + ); + expect(JobsAPI.destroy).toHaveBeenCalledTimes(1); + }); + + test('filter should be enabled after job finishes', async () => { + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'JobEvent', el => el.length > 0); + expect(wrapper.find('Search').props().isDisabled).toBe(false); + }); + + test('filter should be disabled while job is running', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'JobEvent', el => el.length > 0); + expect(wrapper.find('Search').props().isDisabled).toBe(true); + }); + + test('filter should trigger api call and display correct rows', async () => { + const searchBtn = 'button[aria-label="Search submit button"]'; + const searchTextInput = 'input[aria-label="Search text input"]'; + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'JobEvent', el => el.length > 0); + JobsAPI.readEvents.mockClear(); + JobsAPI.readEvents.mockResolvedValueOnce({ + data: mockFilteredJobEventsData, + }); + await act(async () => { + wrapper.find(searchTextInput).instance().value = '99'; + wrapper.find(searchTextInput).simulate('change'); + }); + wrapper.update(); + await act(async () => { + wrapper.find(searchBtn).simulate('click'); + }); + wrapper.update(); + expect(JobsAPI.readEvents).toHaveBeenCalledWith(2, undefined, { + order_by: 'start_line', + page: 1, + page_size: 50, + stdout__icontains: '99', + }); + const jobEvents = wrapper.find('JobEvent'); + expect(jobEvents.at(0).prop('stdout')).toBe( + '\u001b[0;32mok: [localhost] => (item=99) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 99"\u001b[0m\r\n\u001b[0;32m}\u001b[0m' + ); + expect(jobEvents.at(1).prop('stdout')).toBe( + '\u001b[0;32mok: [localhost] => (item=199) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 199"\u001b[0m\r\n\u001b[0;32m}\u001b[0m' ); - 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 act(async () => { + wrapper = mountWithContexts(); + }); await waitForElement(wrapper, 'ContentError', el => el.length === 1); }); }); diff --git a/awx/ui_next/src/screens/Job/JobOutput/PageControls.jsx b/awx/ui_next/src/screens/Job/JobOutput/PageControls.jsx index ac377569f9..a7d8e6fd86 100644 --- a/awx/ui_next/src/screens/Job/JobOutput/PageControls.jsx +++ b/awx/ui_next/src/screens/Job/JobOutput/PageControls.jsx @@ -33,6 +33,7 @@ const PageControls = ({ }) => ( @@ -170,6 +185,7 @@ const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => { ['pending', 'waiting', 'running'].includes(jobStatus) && ( )} - {job.summary_fields.user_capabilities.delete && ['new', 'successful', 'failed', 'error', 'canceled'].includes( jobStatus ) && ( @@ -199,8 +216,13 @@ const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => { }; OutputToolbar.propTypes = { + isDeleteDisabled: bool, job: shape({}).isRequired, onDelete: func.isRequired, }; +OutputToolbar.defaultProps = { + isDeleteDisabled: false, +}; + export default withI18n()(OutputToolbar); diff --git a/awx/ui_next/src/screens/Job/JobOutput/shared/jobOutputUtils.js b/awx/ui_next/src/screens/Job/JobOutput/shared/jobOutputUtils.js new file mode 100644 index 0000000000..cb3e7fb309 --- /dev/null +++ b/awx/ui_next/src/screens/Job/JobOutput/shared/jobOutputUtils.js @@ -0,0 +1,29 @@ +export default function getRowRangePageSize(startIndex, stopIndex) { + let page; + let pageSize; + + if (startIndex === stopIndex) { + page = startIndex + 1; + pageSize = 1; + } else if (stopIndex >= startIndex + 50) { + page = Math.ceil(startIndex / 50); + pageSize = 50; + } else { + for (let i = stopIndex - startIndex + 1; i <= 50; i++) { + if ( + Math.floor(startIndex / i) === Math.floor(stopIndex / i) || + i === 50 + ) { + page = Math.floor(startIndex / i) + 1; + pageSize = i; + break; + } + } + } + + return { + page, + pageSize, + firstIndex: (page - 1) * pageSize, + }; +} diff --git a/awx/ui_next/src/screens/Job/JobOutput/shared/jobOutputUtils.test.jsx b/awx/ui_next/src/screens/Job/JobOutput/shared/jobOutputUtils.test.jsx new file mode 100644 index 0000000000..2c06e347ba --- /dev/null +++ b/awx/ui_next/src/screens/Job/JobOutput/shared/jobOutputUtils.test.jsx @@ -0,0 +1,32 @@ +import getRowRangePageSize from './jobOutputUtils'; + +describe('getRowRangePageSize', () => { + test('handles range of 1', () => { + expect(getRowRangePageSize(1, 1)).toEqual({ + page: 2, + pageSize: 1, + firstIndex: 1, + }); + }); + test('handles range larger than 50 rows', () => { + expect(getRowRangePageSize(55, 125)).toEqual({ + page: 2, + pageSize: 50, + firstIndex: 50, + }); + }); + test('handles small range', () => { + expect(getRowRangePageSize(47, 53)).toEqual({ + page: 6, + pageSize: 9, + firstIndex: 45, + }); + }); + test('handles perfect range', () => { + expect(getRowRangePageSize(5, 9)).toEqual({ + page: 2, + pageSize: 5, + firstIndex: 5, + }); + }); +}); diff --git a/awx/ui_next/src/util/jobs.js b/awx/ui_next/src/util/jobs.js new file mode 100644 index 0000000000..e4129388a5 --- /dev/null +++ b/awx/ui_next/src/util/jobs.js @@ -0,0 +1,3 @@ +export default function isJobRunning(status) { + return ['new', 'pending', 'waiting', 'running'].includes(status); +} diff --git a/awx/ui_next/src/util/jobs.test.js b/awx/ui_next/src/util/jobs.test.js new file mode 100644 index 0000000000..953b06ba17 --- /dev/null +++ b/awx/ui_next/src/util/jobs.test.js @@ -0,0 +1,25 @@ +import isJobRunning from './jobs'; + +describe('isJobRunning', () => { + test('should return true for new', () => { + expect(isJobRunning('new')).toBe(true); + }); + test('should return true for pending', () => { + expect(isJobRunning('pending')).toBe(true); + }); + test('should return true for waiting', () => { + expect(isJobRunning('waiting')).toBe(true); + }); + test('should return true for running', () => { + expect(isJobRunning('running')).toBe(true); + }); + test('should return false for canceled', () => { + expect(isJobRunning('canceled')).toBe(false); + }); + test('should return false for successful', () => { + expect(isJobRunning('successful')).toBe(false); + }); + test('should return false for failed', () => { + expect(isJobRunning('failed')).toBe(false); + }); +});