mirror of
https://github.com/ansible/awx.git
synced 2026-01-17 04:31:21 -03:30
Add support for filtering and pagination on job output
This commit is contained in:
parent
31124e07c6
commit
98da019d12
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
3457
awx/ui_next/src/screens/Job/JobOutput/data.filtered_job_events.json
Normal file
3457
awx/ui_next/src/screens/Job/JobOutput/data.filtered_job_events.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -8,7 +8,7 @@ const BarWrapper = styled.div`
|
||||
background-color: #d7d7d7;
|
||||
display: flex;
|
||||
height: 5px;
|
||||
margin: 24px 0;
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
export default function getRowRangePageSize(startIndex, stopIndex) {
|
||||
let page;
|
||||
let pageSize;
|
||||
|
||||
if (startIndex === stopIndex) {
|
||||
page = startIndex;
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
import getRowRangePageSize from './jobOutputUtils';
|
||||
|
||||
describe('getRowRangePageSize', () => {
|
||||
test('handles range of 1', () => {
|
||||
expect(getRowRangePageSize(1, 1)).toEqual({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
firstIndex: 0,
|
||||
});
|
||||
});
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
3
awx/ui_next/src/util/jobs.js
Normal file
3
awx/ui_next/src/util/jobs.js
Normal file
@ -0,0 +1,3 @@
|
||||
export default function isJobRunning(status) {
|
||||
return ['new', 'pending', 'waiting', 'running'].includes(status);
|
||||
}
|
||||
25
awx/ui_next/src/util/jobs.test.js
Normal file
25
awx/ui_next/src/util/jobs.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user