Merge pull request #9208 from mabashian/job-output-search-2

Add support for filtering and pagination on job output

SUMMARY
link #6612
link #5906
This PR adds the ability to filter job events and also includes logic to handle fetching filtered job events across different pages.
Note that the verbosity dropdown included in #5906 is not included in this work.  I don't think that's possible without api changes.
As part of this work, I converted JobOutput.jsx from a class based component to a functional component.  I've tried my best to make sure that all existing functionality has remained the same by comparing the experience of this branch to devel.
Like the old UI, the output filter is disabled while the job is running.
ISSUE TYPE

Feature Pull Request

COMPONENT NAME

UI

Reviewed-by: Jake McDermott <yo@jakemcdermott.me>
Reviewed-by: Marliana Lara <marliana.lara@gmail.com>
This commit is contained in:
softwarefactory-project-zuul[bot] 2021-03-19 16:38:23 +00:00 committed by GitHub
commit 7a512c1de7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 12386 additions and 8969 deletions

View File

@ -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)

View File

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

View File

@ -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();

View File

@ -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 }) {

View File

@ -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 (
<Dropdown
ouiaId={ouiaId}
isPlain
position={DropdownPosition.right}
isOpen={isOpen}

View File

@ -29,6 +29,7 @@ function AdvancedSearch({
onSearch,
searchableKeys,
relatedSearchableKeys,
maxSelectHeight,
}) {
// TODO: blocked by pf bug, eventually separate these into two groups in the select
// for now, I'm spreading set to get rid of duplicate keys...when they are grouped
@ -91,7 +92,7 @@ function AdvancedSearch({
selections={prefixSelection}
isOpen={isPrefixDropdownOpen}
placeholderText={i18n._(t`Set type`)}
maxHeight="500px"
maxHeight={maxSelectHeight}
noResultsFoundText={i18n._(t`No results found`)}
>
<SelectOption
@ -129,7 +130,7 @@ function AdvancedSearch({
placeholderText={i18n._(t`Key`)}
isCreatable
onCreateOption={setKeySelection}
maxHeight="500px"
maxHeight={maxSelectHeight}
noResultsFoundText={i18n._(t`No results found`)}
>
{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`)}
>
<SelectOption
@ -269,11 +270,13 @@ AdvancedSearch.propTypes = {
onSearch: PropTypes.func.isRequired,
searchableKeys: PropTypes.arrayOf(PropTypes.string),
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
maxSelectHeight: PropTypes.string,
};
AdvancedSearch.defaultProps = {
searchableKeys: [],
relatedSearchableKeys: [],
maxSelectHeight: '300px',
};
export default withI18n()(AdvancedSearch);

View File

@ -41,6 +41,8 @@ function Search({
searchableKeys,
relatedSearchableKeys,
onShowAdvancedSearch,
isDisabled,
maxSelectHeight,
}) {
const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false);
const [searchKey, setSearchKey] = useState(
@ -178,6 +180,7 @@ function Search({
selections={searchColumnName}
isOpen={isSearchDropdownOpen}
ouiaId="simple-key-select"
isDisabled={isDisabled}
>
{searchOptions}
</Select>
@ -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]) => (
<SelectOption
@ -241,6 +247,8 @@ function Search({
isOpen={isFilterDropdownOpen}
placeholderText={`Filter By ${name}`}
ouiaId={`filter-by-${key}`}
isDisabled={isDisabled}
maxHeight={maxSelectHeight}
>
<SelectOption key="true" value="true">
{booleanLabels.true || i18n._(t`Yes`)}
@ -265,11 +273,12 @@ function Search({
value={searchValue}
onChange={setSearchValue}
onKeyDown={handleTextKeyDown}
isDisabled={isDisabled}
/>
<div css={!searchValue && `cursor:not-allowed`}>
<Button
variant={ButtonVariant.control}
isDisabled={!searchValue}
isDisabled={!searchValue || isDisabled}
aria-label={i18n._(t`Search submit button`)}
onClick={handleSearch}
>
@ -310,11 +319,15 @@ Search.propTypes = {
onSearch: PropTypes.func,
onRemove: PropTypes.func,
onShowAdvancedSearch: PropTypes.func.isRequired,
isDisabled: PropTypes.bool,
maxSelectHeight: PropTypes.string,
};
Search.defaultProps = {
onSearch: null,
onRemove: null,
isDisabled: false,
maxSelectHeight: '300px',
};
export default withI18n()(withRouter(Search));

View File

@ -26,28 +26,53 @@ function Job({ i18n, setBreadcrumb }) {
const { id, type } = useParams();
const match = useRouteMatch();
const { isLoading, error, request: fetchJob, result } = useRequest(
const {
isLoading,
error,
request: fetchJob,
result: { jobDetail, eventRelatedSearchableKeys, eventSearchableKeys },
} = useRequest(
useCallback(async () => {
const { data } = await JobsAPI.readDetail(id, type);
const { data: jobDetailData } = await JobsAPI.readDetail(id, type);
const { data: jobEventOptions } = await JobsAPI.readEventOptions(
id,
type
);
if (
data?.summary_fields?.credentials?.find(cred => cred.kind === 'vault')
jobDetailData?.summary_fields?.credentials?.find(
cred => cred.kind === 'vault'
)
) {
const {
data: { results },
} = await JobsAPI.readCredentials(data.id, type);
} = await JobsAPI.readCredentials(jobDetailData.id, type);
data.summary_fields.credentials = results;
jobDetailData.summary_fields.credentials = results;
}
setBreadcrumb(data);
return data;
}, [id, type, setBreadcrumb])
setBreadcrumb(jobDetailData);
return {
jobDetail: jobDetailData,
eventRelatedSearchableKeys: (
jobEventOptions?.related_search_fields || []
).map(val => val.slice(0, -8)),
eventSearchableKeys: Object.keys(
jobEventOptions.actions?.GET || {}
).filter(key => jobEventOptions.actions?.GET[key].filterable),
};
}, [id, type, setBreadcrumb]),
{
jobDetail: null,
eventRelatedSearchableKeys: [],
eventSearchableKeys: [],
}
);
useEffect(() => {
fetchJob();
}, [fetchJob]);
const job = useWsJob(result);
const job = useWsJob(jobDetail);
const tabsArray = [
{
@ -112,7 +137,12 @@ function Job({ i18n, setBreadcrumb }) {
<JobDetail type={type} job={job} />
</Route>,
<Route key="output" path="/jobs/:type/:id/output">
<JobOutput type={type} job={job} />
<JobOutput
type={type}
job={job}
eventRelatedSearchableKeys={eventRelatedSearchableKeys}
eventSearchableKeys={eventSearchableKeys}
/>
</Route>,
<Route key="not-found" path="*">
<ContentError isNotFound>

File diff suppressed because it is too large Load Diff

View File

@ -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('<JobOutput />', () => {
let wrapper;
const mockJob = mockJobData;
const mockJobEvents = mockJobEventsData;
const scrollMock = jest.fn();
beforeEach(() => {
JobsAPI.readEvents.mockResolvedValue({
@ -64,289 +100,194 @@ describe('<JobOutput />', () => {
});
test('initially renders succesfully', async () => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
await act(async () => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
});
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(<JobOutput job={mockJob} />);
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(<JobOutput job={mockJob} />);
});
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(<JobOutput job={mockJob} />);
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(<JobOutput job={mockJob} />);
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(<JobOutput job={mockJob} />);
await act(async () => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
});
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(<JobOutput job={mockJob} />);
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(<JobOutput job={mockJob} />);
});
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(<JobOutput job={mockJob} />);
});
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(
<JobOutput job={{ ...mockJob, status: 'running' }} />
);
});
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(<JobOutput job={mockJob} />);
});
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(<JobOutput job={mockJob} />);
await act(async () => {
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
});

View File

@ -33,6 +33,7 @@ const PageControls = ({
}) => (
<Wrapper>
<Button
ouiaId="job-output-expand-collapse-lines-button"
aria-label={i18n._(t`Toggle expand/collapse event lines`)}
variant="plain"
css="margin-right: auto"
@ -40,6 +41,7 @@ const PageControls = ({
<PlusIcon />
</Button>
<Button
ouiaId="job-output-scroll-previous-button"
aria-label={i18n._(t`Scroll previous`)}
onClick={onScrollPrevious}
variant="plain"
@ -47,6 +49,7 @@ const PageControls = ({
<AngleUpIcon />
</Button>
<Button
ouiaId="job-output-scroll-next-button"
aria-label={i18n._(t`Scroll next`)}
onClick={onScrollNext}
variant="plain"
@ -54,6 +57,7 @@ const PageControls = ({
<AngleDownIcon />
</Button>
<Button
ouiaId="job-output-scroll-first-button"
aria-label={i18n._(t`Scroll first`)}
onClick={onScrollFirst}
variant="plain"
@ -61,6 +65,7 @@ const PageControls = ({
<AngleDoubleUpIcon />
</Button>
<Button
ouiaId="job-output-scroll-last-button"
aria-label={i18n._(t`Scroll last`)}
onClick={onScrollLast}
variant="plain"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ const BarWrapper = styled.div`
background-color: #d7d7d7;
display: flex;
height: 5px;
margin: 24px 0;
margin-top: 16px;
width: 100%;
`;

View File

@ -2,7 +2,7 @@ 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 { bool, shape, func } from 'prop-types';
import {
MinusCircleIcon,
DownloadIcon,
@ -62,7 +62,14 @@ const OUTPUT_NO_COUNT_JOB_TYPES = [
'inventory_update',
];
const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => {
const OutputToolbar = ({
i18n,
job,
onDelete,
onCancel,
isDeleteDisabled,
jobStatus,
}) => {
const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type);
const playCount = job?.playbook_counts?.play_count;
@ -138,13 +145,17 @@ const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => {
{job.status === 'failed' && job.type === 'job' ? (
<LaunchButton resource={job}>
{({ handleRelaunch }) => (
<ReLaunchDropDown handleRelaunch={handleRelaunch} />
<ReLaunchDropDown
handleRelaunch={handleRelaunch}
ouiaId="job-output-relaunch-dropdown"
/>
)}
</LaunchButton>
) : (
<LaunchButton resource={job}>
{({ handleRelaunch }) => (
<Button
ouiaId="job-output-relaunch-button"
variant="plain"
onClick={handleRelaunch}
aria-label={i18n._(t`Relaunch`)}
@ -160,7 +171,11 @@ const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => {
{job.related?.stdout && (
<Tooltip content={i18n._(t`Download Output`)}>
<a href={`${job.related.stdout}?format=txt_download`}>
<Button variant="plain" aria-label={i18n._(t`Download Output`)}>
<Button
ouiaId="job-output-download-button"
variant="plain"
aria-label={i18n._(t`Download Output`)}
>
<DownloadIcon />
</Button>
</a>
@ -170,6 +185,7 @@ const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => {
['pending', 'waiting', 'running'].includes(jobStatus) && (
<Tooltip content={i18n._(t`Cancel Job`)}>
<Button
ouiaId="job-output-cancel-button"
variant="plain"
aria-label={i18n._(t`Cancel Job`)}
onClick={onCancel}
@ -178,17 +194,18 @@ const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => {
</Button>
</Tooltip>
)}
{job.summary_fields.user_capabilities.delete &&
['new', 'successful', 'failed', 'error', 'canceled'].includes(
jobStatus
) && (
<Tooltip content={i18n._(t`Delete Job`)}>
<DeleteButton
ouiaId="job-output-delete-button"
name={job.name}
modalTitle={i18n._(t`Delete Job`)}
onConfirm={onDelete}
variant="plain"
isDisabled={isDeleteDisabled}
>
<TrashAltIcon />
</DeleteButton>
@ -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);

View File

@ -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,
};
}

View File

@ -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,
});
});
});

View File

@ -0,0 +1,3 @@
export default function isJobRunning(status) {
return ['new', 'pending', 'waiting', 'running'].includes(status);
}

View File

@ -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);
});
});