Merge pull request #33 from ansible/devel

Rebase
This commit is contained in:
Sean Sullivan
2021-01-28 08:32:50 -06:00
committed by GitHub
8 changed files with 273 additions and 184 deletions

View File

@@ -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 (
<Detail
label={i18n._(t`Launched By`)}
value={
launchedByLink ? (
<Link to={`${launchedByLink}`}>{launchedByValue}</Link>
) : (
launchedByValue
)
}
/>
);
}

View File

@@ -4,6 +4,7 @@ export { default as DeletedDetail } from './DeletedDetail';
export { default as UserDateDetail } from './UserDateDetail'; export { default as UserDateDetail } from './UserDateDetail';
export { default as DetailBadge } from './DetailBadge'; export { default as DetailBadge } from './DetailBadge';
export { default as ArrayDetail } from './ArrayDetail'; export { default as ArrayDetail } from './ArrayDetail';
export { default as LaunchedByDetail } from './LaunchedByDetail';
/* /*
NOTE: CodeDetail cannot be imported here, as it causes circular NOTE: CodeDetail cannot be imported here, as it causes circular
dependencies in testing environment. Import it directly from dependencies in testing environment. Import it directly from

View File

@@ -7,7 +7,8 @@ import { Card } from '@patternfly/react-core';
import AlertModal from '../AlertModal'; import AlertModal from '../AlertModal';
import DatalistToolbar from '../DataListToolbar'; import DatalistToolbar from '../DataListToolbar';
import ErrorDetail from '../ErrorDetail'; import ErrorDetail from '../ErrorDetail';
import PaginatedDataList, { ToolbarDeleteButton } from '../PaginatedDataList'; import { ToolbarDeleteButton } from '../PaginatedDataList';
import PaginatedTable, { HeaderRow, HeaderCell } from '../PaginatedTable';
import useRequest, { import useRequest, {
useDeleteItems, useDeleteItems,
useDismissableError, useDismissableError,
@@ -27,7 +28,7 @@ import {
} from '../../api'; } from '../../api';
function JobList({ i18n, defaultParams, showTypeColumn = false }) { function JobList({ i18n, defaultParams, showTypeColumn = false }) {
const QS_CONFIG = getQSConfig( const qsConfig = getQSConfig(
'job', 'job',
{ {
page: 1, page: 1,
@@ -49,7 +50,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
} = useRequest( } = useRequest(
useCallback( useCallback(
async () => { async () => {
const params = parseQueryString(QS_CONFIG, location.search); const params = parseQueryString(qsConfig, location.search);
const [response, actionsResponse] = await Promise.all([ const [response, actionsResponse] = await Promise.all([
UnifiedJobsAPI.read({ ...params }), UnifiedJobsAPI.read({ ...params }),
UnifiedJobsAPI.readOptions(), UnifiedJobsAPI.readOptions(),
@@ -81,7 +82,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
// TODO: update QS_CONFIG to be safe for deps array // TODO: update QS_CONFIG to be safe for deps array
const fetchJobsById = useCallback( const fetchJobsById = useCallback(
async ids => { async ids => {
const params = parseQueryString(QS_CONFIG, location.search); const params = parseQueryString(qsConfig, location.search);
params.id__in = ids.join(','); params.id__in = ids.join(',');
const { data } = await UnifiedJobsAPI.read(params); const { data } = await UnifiedJobsAPI.read(params);
return data.results; return data.results;
@@ -89,7 +90,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
[location.search] // eslint-disable-line react-hooks/exhaustive-deps [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; const isAllSelected = selected.length === jobs.length && selected.length > 0;
@@ -145,7 +146,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
); );
}, [selected]), }, [selected]),
{ {
qsConfig: QS_CONFIG, qsConfig,
allItemsSelected: isAllSelected, allItemsSelected: isAllSelected,
fetchItems: fetchJobs, fetchItems: fetchJobs,
} }
@@ -176,14 +177,13 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
return ( return (
<> <>
<Card> <Card>
<PaginatedDataList <PaginatedTable
contentError={contentError} contentError={contentError}
hasContentLoading={isLoading || isDeleteLoading || isCancelLoading} hasContentLoading={isLoading || isDeleteLoading || isCancelLoading}
items={jobs} items={jobs}
itemCount={count} itemCount={count}
pluralizedItemName={i18n._(t`Jobs`)} pluralizedItemName={i18n._(t`Jobs`)}
qsConfig={QS_CONFIG} qsConfig={qsConfig}
onRowClick={handleSelect}
toolbarSearchColumns={[ toolbarSearchColumns={[
{ {
name: i18n._(t`Name`), name: i18n._(t`Name`),
@@ -233,32 +233,17 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
key: 'job__limit', key: 'job__limit',
}, },
]} ]}
toolbarSortColumns={[ headerRow={
{ <HeaderRow qsConfig={qsConfig} isExpandable>
name: i18n._(t`Finish Time`), <HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
key: 'finished', <HeaderCell sortKey="status">{i18n._(t`Status`)}</HeaderCell>
}, {showTypeColumn && <HeaderCell>{i18n._(t`Type`)}</HeaderCell>}
{ <HeaderCell sortKey="started">{i18n._(t`Start Time`)}</HeaderCell>
name: i18n._(t`ID`), <HeaderCell sortKey="finished">
key: 'id', {i18n._(t`Finish Time`)}
}, </HeaderCell>
{ </HeaderRow>
name: i18n._(t`Launched By`), }
key: 'created_by__id',
},
{
name: i18n._(t`Name`),
key: 'name',
},
{
name: i18n._(t`Project`),
key: 'unified_job_template__project__id',
},
{
name: i18n._(t`Start Time`),
key: 'started',
},
]}
toolbarSearchableKeys={searchableKeys} toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys} toolbarRelatedSearchableKeys={relatedSearchableKeys}
renderToolbar={props => ( renderToolbar={props => (
@@ -267,7 +252,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
showSelectAll showSelectAll
isAllSelected={isAllSelected} isAllSelected={isAllSelected}
onSelectAll={handleSelectAll} onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG} qsConfig={qsConfig}
additionalControls={[ additionalControls={[
<ToolbarDeleteButton <ToolbarDeleteButton
key="delete" key="delete"
@@ -283,13 +268,14 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
]} ]}
/> />
)} )}
renderItem={job => ( renderRow={(job, index) => (
<JobListItem <JobListItem
key={job.id} key={job.id}
job={job} job={job}
showTypeColumn={showTypeColumn} showTypeColumn={showTypeColumn}
onSelect={() => handleSelect(job)} onSelect={() => handleSelect(job)}
isSelected={selected.some(row => row.id === job.id)} isSelected={selected.some(row => row.id === job.id)}
rowIndex={index}
/> />
)} )}
/> />

View File

@@ -1,39 +1,29 @@
import React from 'react'; import React, { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { import { Button, Chip } from '@patternfly/react-core';
Button, import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
DataListAction as _DataListAction,
DataListCheck,
DataListItem,
DataListItemRow,
DataListItemCells,
Tooltip,
} from '@patternfly/react-core';
import { RocketIcon } from '@patternfly/react-icons'; import { RocketIcon } from '@patternfly/react-icons';
import styled from 'styled-components'; import { ActionsTd, ActionItem } from '../PaginatedTable';
import DataListCell from '../DataListCell';
import LaunchButton from '../LaunchButton'; 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 { formatDateString } from '../../util/dates';
import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; 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({ function JobListItem({
i18n, i18n,
job, job,
rowIndex,
isSelected, isSelected,
onSelect, onSelect,
showTypeColumn = false, showTypeColumn = false,
}) { }) {
const labelId = `check-action-${job.id}`; const labelId = `check-action-${job.id}`;
const [isExpanded, setIsExpanded] = useState(false);
const jobTypes = { const jobTypes = {
project_update: i18n._(t`Source Control Update`), project_update: i18n._(t`Source Control Update`),
@@ -44,67 +34,123 @@ function JobListItem({
workflow_job: i18n._(t`Workflow Job`), workflow_job: i18n._(t`Workflow Job`),
}; };
const { credentials, inventory, labels } = job.summary_fields;
return ( return (
<DataListItem aria-labelledby={labelId} id={`${job.id}`}> <>
<DataListItemRow> <Tr id={`job-row-${job.id}`}>
<DataListCheck <Td
id={`select-job-${job.id}`} expand={{
checked={isSelected} rowIndex: job.id,
onChange={onSelect} isExpanded,
aria-labelledby={labelId} onToggle: () => setIsExpanded(!isExpanded),
}}
/> />
<DataListItemCells <Td
dataListCells={[ select={{
<DataListCell key="status" isFilled={false}> rowIndex,
{job.status && <StatusIcon status={job.status} />} isSelected,
</DataListCell>, onSelect,
<DataListCell key="name"> }}
<span> dataLabel={i18n._(t`Select`)}
<Link to={`/jobs/${JOB_TYPE_URL_SEGMENTS[job.type]}/${job.id}`}>
<b>
{job.id} &mdash; {job.name}
</b>
</Link>
</span>
</DataListCell>,
...(showTypeColumn
? [
<DataListCell key="type" aria-label="type">
{jobTypes[job.type]}
</DataListCell>,
]
: []),
<DataListCell key="finished">
{job.finished ? formatDateString(job.finished) : ''}
</DataListCell>,
]}
/> />
<DataListAction <Td id={labelId} dataLabel={i18n._(t`Name`)}>
aria-label="actions" <span>
aria-labelledby={labelId} <Link to={`/jobs/${JOB_TYPE_URL_SEGMENTS[job.type]}/${job.id}`}>
id={labelId} <b>
> {job.id} &mdash; {job.name}
{job.type !== 'system_job' && </b>
job.summary_fields?.user_capabilities?.start ? ( </Link>
<Tooltip content={i18n._(t`Relaunch Job`)} position="top"> </span>
<LaunchButton resource={job}> </Td>
{({ handleRelaunch }) => ( <Td dataLabel={i18n._(t`Status`)}>
<Button {job.status && <StatusLabel status={job.status} />}
variant="plain" </Td>
onClick={handleRelaunch} {showTypeColumn && (
aria-label={i18n._(t`Relaunch`)} <Td dataLabel={i18n._(t`Type`)}>{jobTypes[job.type]}</Td>
> )}
<RocketIcon /> <Td dataLabel={i18n._(t`Start Time`)}>
</Button> {formatDateString(job.started)}
)} </Td>
</LaunchButton> <Td dataLabel={i18n._(t`Finish Time`)}>
</Tooltip> {job.finished ? formatDateString(job.finished) : ''}
) : ( </Td>
'' <ActionsTd dataLabel={i18n._(t`Actions`)}>
)} <ActionItem
</DataListAction> visible={
</DataListItemRow> job.type !== 'system_job' &&
</DataListItem> job.summary_fields?.user_capabilities?.start
}
tooltip={i18n._(t`Relaunch Job`)}
>
<LaunchButton resource={job}>
{({ handleRelaunch }) => (
<Button
variant="plain"
onClick={handleRelaunch}
aria-label={i18n._(t`Relaunch`)}
>
<RocketIcon />
</Button>
)}
</LaunchButton>
</ActionItem>
</ActionsTd>
</Tr>
<Tr isExpanded={isExpanded} id={`expanded-job-row-${job.id}`}>
<Td colSpan={2} />
<Td colSpan={showTypeColumn ? 5 : 4}>
<ExpandableRowContent>
<DetailList>
<LaunchedByDetail job={job} i18n={i18n} />
{credentials && credentials.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Credentials`)}
value={
<ChipGroup numChips={5} totalChips={credentials.length}>
{credentials.map(c => (
<CredentialChip key={c.id} credential={c} isReadOnly />
))}
</ChipGroup>
}
/>
)}
{labels && labels.count > 0 && (
<Detail
label={i18n._(t`Labels`)}
value={
<ChipGroup numChips={5} totalChips={labels.results.length}>
{labels.results.map(l => (
<Chip key={l.id} isReadOnly>
{l.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
{inventory && (
<Detail
label={i18n._(t`Inventory`)}
value={
<Link
to={
inventory.kind === 'smart'
? `/inventories/smart_inventory/${inventory.id}`
: `/inventories/inventory/${inventory.id}`
}
>
{inventory.name}
</Link>
}
/>
)}
</DetailList>
</ExpandableRowContent>
</Td>
</Tr>
</>
); );
} }

View File

@@ -32,7 +32,11 @@ describe('<JobListItem />', () => {
initialEntries: ['/jobs'], initialEntries: ['/jobs'],
}); });
wrapper = mountWithContexts( wrapper = mountWithContexts(
<JobListItem job={mockJob} isSelected onSelect={() => {}} />, <table>
<tbody>
<JobListItem job={mockJob} isSelected onSelect={() => {}} />
</tbody>
</table>,
{ context: { router: { history } } } { context: { router: { history } } }
); );
}); });
@@ -51,32 +55,40 @@ describe('<JobListItem />', () => {
test('launch button hidden from users without launch capabilities', () => { test('launch button hidden from users without launch capabilities', () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<JobListItem <table>
job={{ <tbody>
...mockJob, <JobListItem
summary_fields: { user_capabilities: { start: false } }, job={{
}} ...mockJob,
detailUrl={`/jobs/playbook/${mockJob.id}`} summary_fields: { user_capabilities: { start: false } },
onSelect={() => {}} }}
isSelected={false} detailUrl={`/jobs/playbook/${mockJob.id}`}
/> onSelect={() => {}}
isSelected={false}
/>
</tbody>
</table>
); );
expect(wrapper.find('LaunchButton').length).toBe(0); expect(wrapper.find('LaunchButton').length).toBe(0);
}); });
test('should hide type column when showTypeColumn is false', () => { 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', () => { test('should show type column when showTypeColumn is true', () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<JobListItem <table>
job={mockJob} <tbody>
showTypeColumn <JobListItem
isSelected job={mockJob}
onSelect={() => {}} showTypeColumn
/> isSelected
onSelect={() => {}}
/>
</tbody>
</table>
); );
expect(wrapper.find('DataListCell[aria-label="type"]').length).toBe(1); expect(wrapper.find('Td[dataLabel="Type"]').length).toBe(1);
}); });
}); });

View File

@@ -12,7 +12,7 @@ const Th = styled(PFTh)`
--pf-c-table--cell--Overflow: initial; --pf-c-table--cell--Overflow: initial;
`; `;
export default function HeaderRow({ qsConfig, children }) { export default function HeaderRow({ qsConfig, isExpandable, children }) {
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
@@ -41,25 +41,38 @@ export default function HeaderRow({ qsConfig, children }) {
index: sortKey || qsConfig.defaultParams?.order_by, index: sortKey || qsConfig.defaultParams?.order_by,
direction: params.order_by?.startsWith('-') ? 'desc' : 'asc', direction: params.order_by?.startsWith('-') ? 'desc' : 'asc',
}; };
const idPrefix = `${qsConfig.namespace}-table-sort`;
// empty first Th aligns with checkboxes in table rows // empty first Th aligns with checkboxes in table rows
return ( return (
<Thead> <Thead>
<Tr> <Tr>
{isExpandable && <Th />}
<Th /> <Th />
{React.Children.map(children, child => {React.Children.map(
React.cloneElement(child, { children,
onSort, child =>
sortBy, child &&
columnIndex: child.props.sortKey, React.cloneElement(child, {
}) onSort,
sortBy,
columnIndex: child.props.sortKey,
idPrefix,
})
)} )}
</Tr> </Tr>
</Thead> </Thead>
); );
} }
export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) { export function HeaderCell({
sortKey,
onSort,
sortBy,
columnIndex,
idPrefix,
children,
}) {
const sort = sortKey const sort = sortKey
? { ? {
onSort: (event, key, order) => onSort(sortKey, order), onSort: (event, key, order) => onSort(sortKey, order),
@@ -67,5 +80,9 @@ export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) {
columnIndex, columnIndex,
} }
: null; : null;
return <Th sort={sort}>{children}</Th>; return (
<Th sort={sort} id={sortKey ? `${idPrefix}-${sortKey}` : null}>
{children}
</Th>
);
} }

View File

@@ -62,4 +62,20 @@ describe('<HeaderRow />', () => {
const cell = wrapper.find('Th').at(2); const cell = wrapper.find('Th').at(2);
expect(cell.prop('sort')).toEqual(null); expect(cell.prop('sort')).toEqual(null);
}); });
test('should handle null children gracefully', async () => {
const nope = false;
const wrapper = mountWithContexts(
<table>
<HeaderRow qsConfig={qsConfig}>
<HeaderCell sortKey="one">One</HeaderCell>
{nope && <HeaderCell>Hidden</HeaderCell>}
<HeaderCell>Two</HeaderCell>
</HeaderRow>
</table>
);
const cells = wrapper.find('Th');
expect(cells).toHaveLength(3);
});
}); });

View File

@@ -11,6 +11,7 @@ import {
DetailList, DetailList,
Detail, Detail,
UserDateDetail, UserDateDetail,
LaunchedByDetail,
} from '../../../components/DetailList'; } from '../../../components/DetailList';
import { CardBody, CardActionsRow } from '../../../components/Card'; import { CardBody, CardActionsRow } from '../../../components/Card';
import ChipGroup from '../../../components/ChipGroup'; import ChipGroup from '../../../components/ChipGroup';
@@ -53,35 +54,6 @@ const VERBOSITY = {
4: '4 (Connection Debug)', 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 }) { function JobDetail({ job, i18n }) {
const { const {
created_by, created_by,
@@ -107,9 +79,6 @@ function JobDetail({ job, i18n }) {
workflow_job: i18n._(t`Workflow Job`), workflow_job: i18n._(t`Workflow Job`),
}; };
const { value: launchedByValue, link: launchedByLink } =
getLaunchedByDetails(job) || {};
const deleteJob = async () => { const deleteJob = async () => {
try { try {
switch (job.type) { switch (job.type) {
@@ -207,16 +176,7 @@ function JobDetail({ job, i18n }) {
/> />
)} )}
<Detail label={i18n._(t`Job Type`)} value={jobTypes[job.type]} /> <Detail label={i18n._(t`Job Type`)} value={jobTypes[job.type]} />
<Detail <LaunchedByDetail job={job} i18n={i18n} />
label={i18n._(t`Launched By`)}
value={
launchedByLink ? (
<Link to={`${launchedByLink}`}>{launchedByValue}</Link>
) : (
launchedByValue
)
}
/>
{inventory && ( {inventory && (
<Detail <Detail
label={i18n._(t`Inventory`)} label={i18n._(t`Inventory`)}