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 "modules": true
} }
}, },
"plugins": ["react-hooks", "jsx-a11y", "i18next"], "plugins": ["react-hooks", "jsx-a11y", "i18next"],
"extends": ["airbnb", "prettier", "prettier/react", "plugin:jsx-a11y/strict", "plugin:i18next/recommended"], "extends": [
"airbnb",
"prettier",
"prettier/react",
"plugin:jsx-a11y/strict",
"plugin:i18next/recommended"
],
"settings": { "settings": {
"react": { "react": {
"version": "16.5.2" "version": "16.5.2"
@@ -24,7 +30,70 @@
"window": true "window": true
}, },
"rules": { "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", "camelcase": "off",
"arrow-parens": "off", "arrow-parens": "off",
"comma-dangle": "off", "comma-dangle": "off",

View File

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

View File

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

View File

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

View File

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

View File

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