diff --git a/awx/ui_next/src/components/DetailList/LaunchedByDetail.jsx b/awx/ui_next/src/components/DetailList/LaunchedByDetail.jsx new file mode 100644 index 0000000000..6c451a3e51 --- /dev/null +++ b/awx/ui_next/src/components/DetailList/LaunchedByDetail.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { t } from '@lingui/macro'; +import Detail from './Detail'; + +const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => { + const { + created_by: createdBy, + job_template: jobTemplate, + schedule, + } = summary_fields; + const { schedule: relatedSchedule } = related; + + if (!createdBy && !schedule) { + return {}; + } + + let link; + let value; + + if (createdBy) { + link = `/users/${createdBy.id}`; + value = createdBy.username; + } else if (relatedSchedule && jobTemplate) { + link = `/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`; + value = schedule.name; + } else { + link = null; + value = schedule.name; + } + + return { link, value }; +}; + +export default function LaunchedByDetail({ job, i18n }) { + const { value: launchedByValue, link: launchedByLink } = + getLaunchedByDetails(job) || {}; + + return ( + {launchedByValue} + ) : ( + launchedByValue + ) + } + /> + ); +} diff --git a/awx/ui_next/src/components/DetailList/index.js b/awx/ui_next/src/components/DetailList/index.js index d5e2ccd8a4..a393fe72a0 100644 --- a/awx/ui_next/src/components/DetailList/index.js +++ b/awx/ui_next/src/components/DetailList/index.js @@ -4,6 +4,7 @@ export { default as DeletedDetail } from './DeletedDetail'; export { default as UserDateDetail } from './UserDateDetail'; export { default as DetailBadge } from './DetailBadge'; export { default as ArrayDetail } from './ArrayDetail'; +export { default as LaunchedByDetail } from './LaunchedByDetail'; /* NOTE: CodeDetail cannot be imported here, as it causes circular dependencies in testing environment. Import it directly from diff --git a/awx/ui_next/src/components/JobList/JobList.jsx b/awx/ui_next/src/components/JobList/JobList.jsx index ead8916fe8..f21e8bb15b 100644 --- a/awx/ui_next/src/components/JobList/JobList.jsx +++ b/awx/ui_next/src/components/JobList/JobList.jsx @@ -7,7 +7,8 @@ import { Card } from '@patternfly/react-core'; import AlertModal from '../AlertModal'; import DatalistToolbar from '../DataListToolbar'; import ErrorDetail from '../ErrorDetail'; -import PaginatedDataList, { ToolbarDeleteButton } from '../PaginatedDataList'; +import { ToolbarDeleteButton } from '../PaginatedDataList'; +import PaginatedTable, { HeaderRow, HeaderCell } from '../PaginatedTable'; import useRequest, { useDeleteItems, useDismissableError, @@ -27,7 +28,7 @@ import { } from '../../api'; function JobList({ i18n, defaultParams, showTypeColumn = false }) { - const QS_CONFIG = getQSConfig( + const qsConfig = getQSConfig( 'job', { page: 1, @@ -49,7 +50,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { } = useRequest( useCallback( async () => { - const params = parseQueryString(QS_CONFIG, location.search); + const params = parseQueryString(qsConfig, location.search); const [response, actionsResponse] = await Promise.all([ UnifiedJobsAPI.read({ ...params }), UnifiedJobsAPI.readOptions(), @@ -81,7 +82,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { // TODO: update QS_CONFIG to be safe for deps array const fetchJobsById = useCallback( async ids => { - const params = parseQueryString(QS_CONFIG, location.search); + const params = parseQueryString(qsConfig, location.search); params.id__in = ids.join(','); const { data } = await UnifiedJobsAPI.read(params); return data.results; @@ -89,7 +90,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { [location.search] // eslint-disable-line react-hooks/exhaustive-deps ); - const jobs = useWsJobs(results, fetchJobsById, QS_CONFIG); + const jobs = useWsJobs(results, fetchJobsById, qsConfig); const isAllSelected = selected.length === jobs.length && selected.length > 0; @@ -145,7 +146,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { ); }, [selected]), { - qsConfig: QS_CONFIG, + qsConfig, allItemsSelected: isAllSelected, fetchItems: fetchJobs, } @@ -176,14 +177,13 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { return ( <> - + {i18n._(t`Name`)} + {i18n._(t`Status`)} + {showTypeColumn && {i18n._(t`Type`)}} + {i18n._(t`Start Time`)} + + {i18n._(t`Finish Time`)} + + + } toolbarSearchableKeys={searchableKeys} toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( @@ -267,7 +252,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { showSelectAll isAllSelected={isAllSelected} onSelectAll={handleSelectAll} - qsConfig={QS_CONFIG} + qsConfig={qsConfig} additionalControls={[ )} - renderItem={job => ( + renderRow={(job, index) => ( handleSelect(job)} isSelected={selected.some(row => row.id === job.id)} + rowIndex={index} /> )} /> diff --git a/awx/ui_next/src/components/JobList/JobListItem.jsx b/awx/ui_next/src/components/JobList/JobListItem.jsx index be3739ce6e..6e2bff2bf4 100644 --- a/awx/ui_next/src/components/JobList/JobListItem.jsx +++ b/awx/ui_next/src/components/JobList/JobListItem.jsx @@ -1,39 +1,29 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Link } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { - Button, - DataListAction as _DataListAction, - DataListCheck, - DataListItem, - DataListItemRow, - DataListItemCells, - Tooltip, -} from '@patternfly/react-core'; +import { Button, Chip } from '@patternfly/react-core'; +import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table'; import { RocketIcon } from '@patternfly/react-icons'; -import styled from 'styled-components'; -import DataListCell from '../DataListCell'; +import { ActionsTd, ActionItem } from '../PaginatedTable'; import LaunchButton from '../LaunchButton'; -import StatusIcon from '../StatusIcon'; +import StatusLabel from '../StatusLabel'; +import { DetailList, Detail, LaunchedByDetail } from '../DetailList'; +import ChipGroup from '../ChipGroup'; +import CredentialChip from '../CredentialChip'; import { formatDateString } from '../../util/dates'; import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; -const DataListAction = styled(_DataListAction)` - align-items: center; - display: grid; - grid-gap: 16px; - grid-template-columns: 40px; -`; - function JobListItem({ i18n, job, + rowIndex, isSelected, onSelect, showTypeColumn = false, }) { const labelId = `check-action-${job.id}`; + const [isExpanded, setIsExpanded] = useState(false); const jobTypes = { project_update: i18n._(t`Source Control Update`), @@ -44,67 +34,123 @@ function JobListItem({ workflow_job: i18n._(t`Workflow Job`), }; + const { credentials, inventory, labels } = job.summary_fields; + return ( - - - + + setIsExpanded(!isExpanded), + }} /> - - {job.status && } - , - - - - - {job.id} — {job.name} - - - - , - ...(showTypeColumn - ? [ - - {jobTypes[job.type]} - , - ] - : []), - - {job.finished ? formatDateString(job.finished) : ''} - , - ]} + - - {job.type !== 'system_job' && - job.summary_fields?.user_capabilities?.start ? ( - - - {({ handleRelaunch }) => ( - - )} - - - ) : ( - '' - )} - - - + + + + + {job.id} — {job.name} + + + + + + {job.status && } + + {showTypeColumn && ( + {jobTypes[job.type]} + )} + + {formatDateString(job.started)} + + + {job.finished ? formatDateString(job.finished) : ''} + + + + + {({ handleRelaunch }) => ( + + )} + + + + + + + + + + + {credentials && credentials.length > 0 && ( + + {credentials.map(c => ( + + ))} + + } + /> + )} + {labels && labels.count > 0 && ( + + {labels.results.map(l => ( + + {l.name} + + ))} + + } + /> + )} + {inventory && ( + + {inventory.name} + + } + /> + )} + + + + + ); } diff --git a/awx/ui_next/src/components/JobList/JobListItem.test.jsx b/awx/ui_next/src/components/JobList/JobListItem.test.jsx index fc453c3be4..6b9a9c3ae4 100644 --- a/awx/ui_next/src/components/JobList/JobListItem.test.jsx +++ b/awx/ui_next/src/components/JobList/JobListItem.test.jsx @@ -32,7 +32,11 @@ describe('', () => { initialEntries: ['/jobs'], }); wrapper = mountWithContexts( - {}} />, + + + {}} /> + +
, { context: { router: { history } } } ); }); @@ -51,32 +55,40 @@ describe('', () => { test('launch button hidden from users without launch capabilities', () => { wrapper = mountWithContexts( - {}} - isSelected={false} - /> + + + {}} + isSelected={false} + /> + +
); expect(wrapper.find('LaunchButton').length).toBe(0); }); test('should hide type column when showTypeColumn is false', () => { - expect(wrapper.find('DataListCell[aria-label="type"]').length).toBe(0); + expect(wrapper.find('Td[dataLabel="Type"]').length).toBe(0); }); test('should show type column when showTypeColumn is true', () => { wrapper = mountWithContexts( - {}} - /> + + + {}} + /> + +
); - expect(wrapper.find('DataListCell[aria-label="type"]').length).toBe(1); + expect(wrapper.find('Td[dataLabel="Type"]').length).toBe(1); }); }); diff --git a/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx b/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx index 14c5c0b8ee..a70ca52232 100644 --- a/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx +++ b/awx/ui_next/src/components/PaginatedTable/HeaderRow.jsx @@ -12,7 +12,7 @@ const Th = styled(PFTh)` --pf-c-table--cell--Overflow: initial; `; -export default function HeaderRow({ qsConfig, children }) { +export default function HeaderRow({ qsConfig, isExpandable, children }) { const location = useLocation(); const history = useHistory(); @@ -41,25 +41,38 @@ export default function HeaderRow({ qsConfig, children }) { index: sortKey || qsConfig.defaultParams?.order_by, direction: params.order_by?.startsWith('-') ? 'desc' : 'asc', }; + const idPrefix = `${qsConfig.namespace}-table-sort`; // empty first Th aligns with checkboxes in table rows return ( + {isExpandable && } - {React.Children.map(children, child => - React.cloneElement(child, { - onSort, - sortBy, - columnIndex: child.props.sortKey, - }) + {React.Children.map( + children, + child => + child && + React.cloneElement(child, { + onSort, + sortBy, + columnIndex: child.props.sortKey, + idPrefix, + }) )} ); } -export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) { +export function HeaderCell({ + sortKey, + onSort, + sortBy, + columnIndex, + idPrefix, + children, +}) { const sort = sortKey ? { onSort: (event, key, order) => onSort(sortKey, order), @@ -67,5 +80,9 @@ export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) { columnIndex, } : null; - return {children}; + return ( + + {children} + + ); } diff --git a/awx/ui_next/src/components/PaginatedTable/HeaderRow.test.jsx b/awx/ui_next/src/components/PaginatedTable/HeaderRow.test.jsx index ec1124c4b7..fa53baac60 100644 --- a/awx/ui_next/src/components/PaginatedTable/HeaderRow.test.jsx +++ b/awx/ui_next/src/components/PaginatedTable/HeaderRow.test.jsx @@ -62,4 +62,20 @@ describe('', () => { const cell = wrapper.find('Th').at(2); expect(cell.prop('sort')).toEqual(null); }); + + test('should handle null children gracefully', async () => { + const nope = false; + const wrapper = mountWithContexts( + + + One + {nope && Hidden} + Two + +
+ ); + + const cells = wrapper.find('Th'); + expect(cells).toHaveLength(3); + }); }); diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx index f239081f48..c24a92dc24 100644 --- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx +++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx @@ -11,6 +11,7 @@ import { DetailList, Detail, UserDateDetail, + LaunchedByDetail, } from '../../../components/DetailList'; import { CardBody, CardActionsRow } from '../../../components/Card'; import ChipGroup from '../../../components/ChipGroup'; @@ -53,35 +54,6 @@ const VERBOSITY = { 4: '4 (Connection Debug)', }; -const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => { - const { - created_by: createdBy, - job_template: jobTemplate, - schedule, - } = summary_fields; - const { schedule: relatedSchedule } = related; - - if (!createdBy && !schedule) { - return null; - } - - let link; - let value; - - if (createdBy) { - link = `/users/${createdBy.id}`; - value = createdBy.username; - } else if (relatedSchedule && jobTemplate) { - link = `/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`; - value = schedule.name; - } else { - link = null; - value = schedule.name; - } - - return { link, value }; -}; - function JobDetail({ job, i18n }) { const { created_by, @@ -107,9 +79,6 @@ function JobDetail({ job, i18n }) { workflow_job: i18n._(t`Workflow Job`), }; - const { value: launchedByValue, link: launchedByLink } = - getLaunchedByDetails(job) || {}; - const deleteJob = async () => { try { switch (job.type) { @@ -207,16 +176,7 @@ function JobDetail({ job, i18n }) { /> )} - {launchedByValue} - ) : ( - launchedByValue - ) - } - /> + {inventory && (