convert Schedules list to PaginatedTable

This commit is contained in:
Keith Grant 2021-01-06 11:18:53 -08:00
parent 271eb4043a
commit 953fa3fe0d
6 changed files with 222 additions and 146 deletions

View File

@ -8,8 +8,14 @@
"modules": true
}
},
"plugins": ["react-hooks", "jsx-a11y", "i18next"],
"extends": ["airbnb", "prettier", "prettier/react", "plugin:jsx-a11y/strict", "plugin:i18next/recommended"],
"plugins": ["react-hooks", "jsx-a11y", "i18next"],
"extends": [
"airbnb",
"prettier",
"prettier/react",
"plugin:jsx-a11y/strict",
"plugin:i18next/recommended"
],
"settings": {
"react": {
"version": "16.5.2"
@ -24,7 +30,70 @@
"window": true
},
"rules": {
"i18next/no-literal-string": [2, {"markupOnly": true, "ignoreAttribute": ["to", "streamType", "path", "component", "variant", "key", "position", "promptName", "color","promptId", "headingLevel", "size", "target", "autoComplete","trigger", "from", "name", "fieldId", "css", "gutter", "dataCy", "tooltipMaxWidth", "mode", "aria-labelledby","aria-hidden","sortKey", "ouiaId", "credentialTypeNamespace", "link", "value", "credentialTypeKind", "linkTo", "scrollToAlignment", "displayKey", "sortedColumnKey", "maxHeight", "role", "aria-haspopup", "dropDirection", "resizeOrientation", "src", "theme"], "ignore":["Ansible", "Tower", "JSON", "YAML", "lg", "START"],"ignoreComponent":["code", "Omit","PotentialLink", "TypeRedirect", "Radio", "RunOnRadio", "NodeTypeLetter", "SelectableItem", "Dash"], "ignoreCallee": ["describe"] }],
"i18next/no-literal-string": [
2,
{
"markupOnly": true,
"ignoreAttribute": [
"to",
"streamType",
"path",
"component",
"variant",
"key",
"position",
"promptName",
"color",
"promptId",
"headingLevel",
"size",
"target",
"autoComplete",
"trigger",
"from",
"name",
"fieldId",
"css",
"gutter",
"dataCy",
"tooltipMaxWidth",
"mode",
"aria-labelledby",
"aria-hidden",
"sortKey",
"ouiaId",
"credentialTypeNamespace",
"link",
"value",
"credentialTypeKind",
"linkTo",
"scrollToAlignment",
"displayKey",
"sortedColumnKey",
"maxHeight",
"role",
"aria-haspopup",
"dropDirection",
"resizeOrientation",
"src",
"theme",
"gridColumns"
],
"ignore": ["Ansible", "Tower", "JSON", "YAML", "lg", "START"],
"ignoreComponent": [
"code",
"Omit",
"PotentialLink",
"TypeRedirect",
"Radio",
"RunOnRadio",
"NodeTypeLetter",
"SelectableItem",
"Dash"
],
"ignoreCallee": ["describe"]
}
],
"camelcase": "off",
"arrow-parens": "off",
"comma-dangle": "off",

View File

@ -9,7 +9,7 @@ const ActionsGrid = styled.div`
align-items: center;
${props => {
const columns = '40px '.repeat(props.numActions || 1);
const columns = props.gridColumns || '40px '.repeat(props.numActions || 1);
return css`
grid-template-columns: ${columns};
`;
@ -17,7 +17,7 @@ const ActionsGrid = styled.div`
`;
ActionsGrid.displayName = 'ActionsGrid';
export default function ActionsTd({ children, ...props }) {
export default function ActionsTd({ children, gridColumns, ...props }) {
const numActions = children.length || 1;
const width = numActions * 40;
return (
@ -28,7 +28,7 @@ export default function ActionsTd({ children, ...props }) {
`}
{...props}
>
<ActionsGrid numActions={numActions}>
<ActionsGrid numActions={numActions} gridColumns={gridColumns}>
{React.Children.map(children, (child, i) =>
React.cloneElement(child, {
column: i + 1,

View File

@ -6,11 +6,9 @@ import { t } from '@lingui/macro';
import { SchedulesAPI } from '../../../api';
import AlertModal from '../../AlertModal';
import ErrorDetail from '../../ErrorDetail';
import PaginatedTable, { HeaderRow, HeaderCell } from '../../PaginatedTable';
import DataListToolbar from '../../DataListToolbar';
import PaginatedDataList, {
ToolbarAddButton,
ToolbarDeleteButton,
} from '../../PaginatedDataList';
import { ToolbarAddButton, ToolbarDeleteButton } from '../../PaginatedDataList';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import ScheduleListItem from './ScheduleListItem';
@ -119,19 +117,27 @@ function ScheduleList({
return (
<>
<PaginatedDataList
<PaginatedTable
contentError={contentError}
hasContentLoading={isLoading || isDeleteLoading}
items={schedules}
itemCount={itemCount}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
renderItem={item => (
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
<HeaderCell>{i18n._(t`Type`)}</HeaderCell>
<HeaderCell sortKey="next_run">{i18n._(t`Next Run`)}</HeaderCell>
</HeaderRow>
}
renderRow={(item, index) => (
<ScheduleListItem
isSelected={selected.some(row => row.id === item.id)}
key={item.id}
onSelect={() => handleSelect(item)}
schedule={item}
rowIndex={index}
/>
)}
toolbarSearchColumns={[
@ -153,16 +159,6 @@ function ScheduleList({
key: 'modified_by__username__icontains',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
{
name: i18n._(t`Next Run`),
key: 'next_run',
},
]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
renderToolbar={props => (

View File

@ -59,44 +59,61 @@ describe('ScheduleList', () => {
test('should check and uncheck the row item', async () => {
expect(
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
wrapper
.find('.pf-c-table__check')
.first()
.find('input')
.props().checked
).toBe(false);
await act(async () => {
wrapper
.find('DataListCheck[id="select-schedule-1"]')
.find('.pf-c-table__check')
.first()
.find('input')
.invoke('onChange')(true);
});
wrapper.update();
expect(
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
wrapper
.find('.pf-c-table__check')
.first()
.find('input')
.props().checked
).toBe(true);
await act(async () => {
wrapper
.find('DataListCheck[id="select-schedule-1"]')
.find('.pf-c-table__check')
.first()
.find('input')
.invoke('onChange')(false);
});
wrapper.update();
expect(
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
wrapper
.find('.pf-c-table__check')
.first()
.find('input')
.props().checked
).toBe(false);
});
test('should check all row items when select all is checked', async () => {
wrapper.find('DataListCheck').forEach(el => {
expect(wrapper.find('.pf-c-table__check input')).toHaveLength(5);
wrapper.find('.pf-c-table__check input').forEach(el => {
expect(el.props().checked).toBe(false);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
wrapper.find('.pf-c-table__check input').forEach(el => {
expect(el.props().checked).toBe(true);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(false);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
wrapper.find('.pf-c-table__check input').forEach(el => {
expect(el.props().checked).toBe(false);
});
});
@ -104,7 +121,8 @@ describe('ScheduleList', () => {
test('should call api delete schedules for each selected schedule', async () => {
await act(async () => {
wrapper
.find('DataListCheck[id="select-schedule-3"]')
.find('.pf-c-table__check input')
.at(3)
.invoke('onChange')();
});
wrapper.update();
@ -122,7 +140,8 @@ describe('ScheduleList', () => {
expect(wrapper.find('Modal').length).toBe(0);
await act(async () => {
wrapper
.find('DataListCheck[id="select-schedule-2"]')
.find('.pf-c-table__check input')
.at(2)
.invoke('onChange')();
});
wrapper.update();

View File

@ -4,31 +4,16 @@ import { bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
import {
Button,
DataListAction as _DataListAction,
DataListCheck,
DataListItem,
DataListItemRow,
DataListItemCells,
Tooltip,
} from '@patternfly/react-core';
import { Button } from '@patternfly/react-core';
import { Tr, Td } from '@patternfly/react-table';
import { PencilAltIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import DataListCell from '../../DataListCell';
import { DetailList, Detail } from '../../DetailList';
import { ActionsTd, ActionItem } from '../../PaginatedTable';
import { ScheduleToggle } from '..';
import { Schedule } from '../../../types';
import { formatDateString } from '../../../util/dates';
const DataListAction = styled(_DataListAction)`
align-items: center;
display: grid;
grid-gap: 16px;
grid-template-columns: 92px 40px;
`;
function ScheduleListItem({ i18n, isSelected, onSelect, schedule }) {
function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) {
const labelId = `check-action-${schedule.id}`;
const jobTypeLabels = {
@ -62,69 +47,56 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule }) {
}
return (
<DataListItem
key={schedule.id}
aria-labelledby={labelId}
id={`${schedule.id}`}
>
<DataListItemRow>
<DataListCheck
id={`select-schedule-${schedule.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="name">
<Link to={`${scheduleBaseUrl}/details`}>
<b>{schedule.name}</b>
</Link>
</DataListCell>,
<DataListCell key="type">
{
jobTypeLabels[
schedule.summary_fields.unified_job_template.unified_job_type
]
}
</DataListCell>,
<DataListCell key="next_run">
{schedule.next_run && (
<DetailList stacked>
<Detail
label={i18n._(t`Next Run`)}
value={formatDateString(schedule.next_run)}
/>
</DetailList>
)}
</DataListCell>,
]}
/>
<DataListAction
aria-label={i18n._(t`actions`)}
aria-labelledby={labelId}
id={labelId}
key="actions"
<Tr id={`schedule-row-${schedule.id}`}>
<Td
select={{
rowIndex,
isSelected,
onSelect,
disable: false,
}}
dataLabel={i18n._(`Selected`)}
/>
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
<Link to={`${scheduleBaseUrl}/details`}>
<b>{schedule.name}</b>
</Link>
</Td>
<Td dataLabel={i18n._(t`Type`)}>
{
jobTypeLabels[
schedule.summary_fields.unified_job_template.unified_job_type
]
}
</Td>
<Td dataLabel={i18n._(t`Next Run`)}>
{schedule.next_run && (
<DetailList stacked>
<Detail
label={i18n._(t`Next Run`)}
value={formatDateString(schedule.next_run)}
/>
</DetailList>
)}
</Td>
<ActionsTd dataLabel={i18n._(t`Actions`)} gridColumns="auto 40px">
<ScheduleToggle schedule={schedule} />
<ActionItem
visible={schedule.summary_fields.user_capabilities.edit}
tooltip={i18n._(t`Edit Schedule`)}
>
<ScheduleToggle schedule={schedule} />
{schedule.summary_fields.user_capabilities.edit ? (
<Tooltip content={i18n._(t`Edit Schedule`)} position="top">
<Button
aria-label={i18n._(t`Edit Schedule`)}
css="grid-column: 2"
variant="plain"
component={Link}
to={`${scheduleBaseUrl}/edit`}
>
<PencilAltIcon />
</Button>
</Tooltip>
) : (
''
)}
</DataListAction>
</DataListItemRow>
</DataListItem>
<Button
aria-label={i18n._(t`Edit Schedule`)}
css="grid-column: 2"
variant="plain"
component={Link}
to={`${scheduleBaseUrl}/edit`}
>
<PencilAltIcon />
</Button>
</ActionItem>
</ActionsTd>
</Tr>
);
}

View File

@ -50,39 +50,47 @@ describe('ScheduleListItem', () => {
describe('User has edit permissions', () => {
beforeAll(() => {
wrapper = mountWithContexts(
<ScheduleListItem
isSelected={false}
onSelect={onSelect}
schedule={mockSchedule}
/>
<table>
<tbody>
<ScheduleListItem
isSelected={false}
onSelect={onSelect}
schedule={mockSchedule}
/>
</tbody>
</table>
);
});
afterAll(() => {
wrapper.unmount();
});
test('Name correctly shown with correct link', () => {
expect(
wrapper
.find('DataListCell')
.first()
.find('Td')
.at(1)
.text()
).toBe('Mock Schedule');
expect(
wrapper
.find('DataListCell')
.first()
.find('Td')
.at(1)
.find('Link')
.props().to
).toBe('/templates/job_template/12/schedules/6/details');
});
test('Type correctly shown', () => {
expect(
wrapper
.find('DataListCell')
.at(1)
.find('Td')
.at(2)
.text()
).toBe('Playbook Run');
});
test('Edit button shown with correct link', () => {
expect(wrapper.find('PencilAltIcon').length).toBe(1);
expect(
@ -92,6 +100,7 @@ describe('ScheduleListItem', () => {
.props().to
).toBe('/templates/job_template/12/schedules/6/edit');
});
test('Toggle button enabled', () => {
expect(
wrapper
@ -100,63 +109,74 @@ describe('ScheduleListItem', () => {
.props().isDisabled
).toBe(false);
});
test('Clicking checkbox makes expected callback', () => {
test('Clicking checkbox selects item', () => {
wrapper
.find('DataListCheck')
.find('Td')
.first()
.find('input')
.simulate('change');
expect(onSelect).toHaveBeenCalledTimes(1);
});
});
describe('User has read-only permissions', () => {
beforeAll(() => {
wrapper = mountWithContexts(
<ScheduleListItem
isSelected={false}
onSelect={onSelect}
schedule={{
...mockSchedule,
summary_fields: {
...mockSchedule.summary_fields,
user_capabilities: {
edit: false,
delete: false,
},
},
}}
/>
<table>
<tbody>
<ScheduleListItem
isSelected={false}
onSelect={onSelect}
schedule={{
...mockSchedule,
summary_fields: {
...mockSchedule.summary_fields,
user_capabilities: {
edit: false,
delete: false,
},
},
}}
/>
</tbody>
</table>
);
});
afterAll(() => {
wrapper.unmount();
});
test('Name correctly shown with correct link', () => {
expect(
wrapper
.find('DataListCell')
.first()
.find('Td')
.at(1)
.text()
).toBe('Mock Schedule');
expect(
wrapper
.find('DataListCell')
.first()
.find('Td')
.at(1)
.find('Link')
.props().to
).toBe('/templates/job_template/12/schedules/6/details');
});
test('Type correctly shown', () => {
expect(
wrapper
.find('DataListCell')
.at(1)
.find('Td')
.at(2)
.text()
).toBe('Playbook Run');
});
test('Edit button hidden', () => {
expect(wrapper.find('PencilAltIcon').length).toBe(0);
});
test('Toggle button disabled', () => {
expect(
wrapper