Adds functionality for expand all in lists that have list items that can be expanded

This commit is contained in:
Alex Corey 2021-07-08 15:42:21 -04:00
parent adb6661015
commit 6a717a8f3c
12 changed files with 277 additions and 36 deletions

View File

@ -4,6 +4,7 @@ import styled from 'styled-components';
import { t } from '@lingui/macro';
import {
Button,
Checkbox,
Toolbar,
ToolbarContent as PFToolbarContent,
@ -14,7 +15,11 @@ import {
DropdownPosition,
KebabToggle,
} from '@patternfly/react-core';
import { SearchIcon } from '@patternfly/react-icons';
import {
AngleDownIcon,
AngleRightIcon,
SearchIcon,
} from '@patternfly/react-icons';
import ExpandCollapse from '../ExpandCollapse';
import Search from '../Search';
import Sort from '../Sort';
@ -28,14 +33,16 @@ const ToolbarContent = styled(PFToolbarContent)`
`;
function DataListToolbar({
isAllExpanded,
onExpandAll,
itemCount,
clearAllFilters,
searchColumns,
searchableKeys,
relatedSearchableKeys,
sortColumns,
showSelectAll,
isAllSelected,
onSelectAll,
isCompact,
onSort,
onSearch,
@ -43,7 +50,6 @@ function DataListToolbar({
onRemove,
onCompact,
onExpand,
onSelectAll,
additionalControls,
qsConfig,
pagination,
@ -70,7 +76,6 @@ function DataListToolbar({
setIsKebabOpen(false);
}
}, [isKebabModalOpen]);
return (
<Toolbar
id={`${qsConfig.namespace}-list-toolbar`}
@ -79,7 +84,27 @@ function DataListToolbar({
clearFiltersButtonText={t`Clear all filters`}
>
<ToolbarContent>
{showSelectAll && (
{onExpandAll && (
<ToolbarGroup>
<ToolbarItem>
<Button
onClick={() => {
onExpandAll(!isAllExpanded);
}}
aria-label={t`Expand all rows`}
ouiaId="expand-all-rows"
variant="plain"
>
{isAllExpanded ? (
<AngleDownIcon aria-label={t`Is expanded`} />
) : (
<AngleRightIcon aria-label={t`Is not expanded`} />
)}
</Button>
</ToolbarItem>
</ToolbarGroup>
)}
{onSelectAll && (
<ToolbarGroup>
<ToolbarItem>
<Checkbox
@ -178,7 +203,6 @@ DataListToolbar.propTypes = {
searchableKeys: PropTypes.arrayOf(PropTypes.string),
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
sortColumns: SortColumns,
showSelectAll: PropTypes.bool,
isAllSelected: PropTypes.bool,
isCompact: PropTypes.bool,
onCompact: PropTypes.func,
@ -198,7 +222,6 @@ DataListToolbar.defaultProps = {
relatedSearchableKeys: [],
sortColumns: null,
clearAllFilters: null,
showSelectAll: false,
isAllSelected: false,
isCompact: false,
onCompact: null,

View File

@ -25,6 +25,7 @@ describe('<DataListToolbar />', () => {
const onReplaceSearch = jest.fn();
const onSort = jest.fn();
const onSelectAll = jest.fn();
const onExpandAll = jest.fn();
test('it triggers the expected callbacks', () => {
const searchColumns = [
@ -353,4 +354,61 @@ describe('<DataListToolbar />', () => {
1
);
});
test('should handle expanded rows', async () => {
const searchColumns = [
{ name: 'Name', key: 'name__icontains', isDefault: true },
];
const sortColumns = [{ name: 'Name', key: 'name' }];
const newtoolbar = mountWithContexts(
<DataListToolbar
qsConfig={QS_CONFIG}
isAllSelected={false}
searchColumns={searchColumns}
sortColumns={sortColumns}
onSearch={onSearch}
onReplaceSearch={onReplaceSearch}
onSort={onSort}
onSelectAll={onSelectAll}
showSelectAll
showExpandAll
isAllExpanded={false}
onExpandAll={onExpandAll}
/>
);
await act(async () =>
newtoolbar.find('Button[aria-label="Expand all rows"]').prop('onClick')()
);
expect(newtoolbar.find('AngleRightIcon')).toHaveLength(1);
expect(newtoolbar.find('AngleDownIcon')).toHaveLength(0);
expect(onExpandAll).toBeCalledWith(true);
});
test('should render angle down icon', async () => {
const searchColumns = [
{ name: 'Name', key: 'name__icontains', isDefault: true },
];
const sortColumns = [{ name: 'Name', key: 'name' }];
const newtoolbar = mountWithContexts(
<DataListToolbar
qsConfig={QS_CONFIG}
isAllSelected={false}
searchColumns={searchColumns}
sortColumns={sortColumns}
onSearch={onSearch}
onReplaceSearch={onReplaceSearch}
onSort={onSort}
onSelectAll={onSelectAll}
showSelectAll
showExpandAll
isAllExpanded
onExpandAll={onExpandAll}
/>
);
expect(newtoolbar.find('AngleDownIcon')).toHaveLength(1);
expect(newtoolbar.find('AngleRightIcon')).toHaveLength(0);
});
});

View File

@ -17,6 +17,7 @@ import useRequest, {
} from '../../util/useRequest';
import { useConfig } from '../../contexts/Config';
import useSelected from '../../util/useSelected';
import useExpanded from '../../util/useExpanded';
import { isJobRunning, getJobModel } from '../../util/jobs';
import { getQSConfig, parseQueryString } from '../../util/qs';
import JobListItem from './JobListItem';
@ -97,6 +98,10 @@ function JobList({ defaultParams, showTypeColumn = false }) {
clearSelected,
} = useSelected(jobs);
const { expanded, isAllExpanded, handleExpand, expandAll } = useExpanded(
jobs
);
const {
error: cancelJobsError,
isLoading: isCancelLoading,
@ -227,7 +232,8 @@ function JobList({ defaultParams, showTypeColumn = false }) {
renderToolbar={props => (
<DatalistToolbar
{...props}
showSelectAll
isAllExpanded={isAllExpanded}
onExpandAll={expandAll}
isAllSelected={isAllSelected}
onSelectAll={selectAll}
qsConfig={qsConfig}
@ -264,6 +270,8 @@ function JobList({ defaultParams, showTypeColumn = false }) {
<JobListItem
key={job.id}
job={job}
isExpanded={expanded.some(row => row.id === job.id)}
onExpand={() => handleExpand(job)}
isSuperUser={me?.is_superuser}
showTypeColumn={showTypeColumn}
onSelect={() => handleSelect(job)}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React from 'react';
import { Link } from 'react-router-dom';
import { t } from '@lingui/macro';
@ -20,6 +20,8 @@ import JobCancelButton from '../JobCancelButton';
const Dash = styled.span``;
function JobListItem({
isExpanded,
onExpand,
job,
rowIndex,
isSelected,
@ -28,7 +30,6 @@ function JobListItem({
isSuperUser = false,
}) {
const labelId = `check-action-${job.id}`;
const [isExpanded, setIsExpanded] = useState(false);
const jobTypes = {
project_update: t`Source Control Update`,
@ -57,7 +58,7 @@ function JobListItem({
expand={{
rowIndex: job.id,
isExpanded,
onToggle: () => setIsExpanded(!isExpanded),
onToggle: onExpand,
}}
/>
<Td

View File

@ -17,6 +17,7 @@ import PaginatedTable, {
} from '../PaginatedTable';
import useRequest, { useDeleteItems } from '../../util/useRequest';
import useSelected from '../../util/useSelected';
import useExpanded from '../../util/useExpanded';
import { getQSConfig, parseQueryString } from '../../util/qs';
import useWsTemplates from '../../util/useWsTemplates';
import AddDropDownButton from '../AddDropDownButton';
@ -97,6 +98,10 @@ function TemplateList({ defaultParams }) {
clearSelected,
} = useSelected(templates);
const { expanded, isAllExpanded, handleExpand, expandAll } = useExpanded(
templates
);
const {
isLoading: isDeleteLoading,
deleteItems: deleteTemplates,
@ -229,9 +234,10 @@ function TemplateList({ defaultParams }) {
renderToolbar={props => (
<DatalistToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={selectAll}
isAllExpanded={isAllExpanded}
onExpandAll={expandAll}
qsConfig={qsConfig}
additionalControls={[
...(canAddJT || canAddWFJT ? [addButton] : []),
@ -259,6 +265,8 @@ function TemplateList({ defaultParams }) {
template={template}
detailUrl={`/templates/${template.type}/${template.id}`}
onSelect={() => handleSelect(template)}
isExpanded={expanded.some(row => row.id === template.id)}
onExpand={() => handleExpand(template)}
isSelected={selected.some(row => row.id === template.id)}
fetchTemplates={fetchTemplates}
rowIndex={index}

View File

@ -34,6 +34,8 @@ const ExclamationTriangleIconWarning = styled(ExclamationTriangleIcon)`
ExclamationTriangleIconWarning.displayName = 'ExclamationTriangleIconWarning';
function TemplateListItem({
isExpanded,
onExpand,
template,
isSelected,
onSelect,
@ -42,7 +44,6 @@ function TemplateListItem({
rowIndex,
}) {
const config = useConfig();
const [isExpanded, setIsExpanded] = useState(false);
const [isDisabled, setIsDisabled] = useState(false);
const labelId = `check-action-${template.id}`;
@ -119,7 +120,7 @@ function TemplateListItem({
expand={{
rowIndex,
isExpanded,
onToggle: () => setIsExpanded(!isExpanded),
onToggle: onExpand,
}}
/>
<Td

View File

@ -381,6 +381,7 @@ describe('<TemplateListItem />', () => {
<tbody>
<TemplateListItem
isSelected={false}
isExpanded
detailUrl="/templates/job_template/1/details"
template={{
...mockJobTemplateData,
@ -390,15 +391,7 @@ describe('<TemplateListItem />', () => {
</tbody>
</table>
);
expect(
wrapper
.find('Tr')
.last()
.prop('isExpanded')
).toBe(false);
await act(async () =>
wrapper.find('button[aria-label="Details"]').simulate('click')
);
wrapper.update();
expect(
wrapper

View File

@ -18,6 +18,7 @@ import PaginatedTable, {
} from '../../../components/PaginatedTable';
import useWsProjects from './useWsProjects';
import useSelected from '../../../util/useSelected';
import useExpanded from '../../../util/useExpanded';
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
import { getQSConfig, parseQueryString } from '../../../util/qs';
@ -103,6 +104,10 @@ function ProjectList() {
clearSelected,
} = useSelected(projects);
const { expanded, isAllExpanded, handleExpand, expandAll } = useExpanded(
projects
);
const {
isLoading: isDeleteLoading,
deleteItems: deleteProjects,
@ -212,7 +217,8 @@ function ProjectList() {
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllExpanded={isAllExpanded}
onExpandAll={expandAll}
isAllSelected={isAllSelected}
onSelectAll={selectAll}
qsConfig={QS_CONFIG}
@ -244,6 +250,8 @@ function ProjectList() {
)}
renderRow={(project, index) => (
<ProjectListItem
isExpanded={expanded.some(row => row.id === project.id)}
onExpand={() => handleExpand(project)}
fetchProjects={fetchProjects}
key={project.id}
project={project}

View File

@ -38,6 +38,8 @@ const ExclamationTriangleIcon = styled(PFExclamationTriangleIcon)`
`;
function ProjectListItem({
isExpanded,
onExpand,
project,
isSelected,
onSelect,
@ -46,7 +48,6 @@ function ProjectListItem({
rowIndex,
onRefreshRow,
}) {
const [isExpanded, setIsExpanded] = useState(false);
const [isDisabled, setIsDisabled] = useState(false);
ProjectListItem.propTypes = {
project: Project.isRequired,
@ -165,7 +166,7 @@ function ProjectListItem({
expand={{
rowIndex,
isExpanded,
onToggle: () => setIsExpanded(!isExpanded),
onToggle: onExpand,
}}
/>
<Td

View File

@ -393,6 +393,7 @@ describe('<ProjectsListItem />', () => {
<tbody>
<ProjectsListItem
rowIndex={1}
isExpanded
isSelected={false}
detailUrl="/project/1"
onSelect={() => {}}
@ -431,16 +432,7 @@ describe('<ProjectsListItem />', () => {
</tbody>
</table>
);
expect(
wrapper
.find('Tr')
.last()
.prop('isExpanded')
).toBe(false);
await act(async () =>
wrapper.find('button[aria-label="Details"]').simulate('click')
);
wrapper.update();
expect(
wrapper
.find('Tr')

View File

@ -0,0 +1,32 @@
import { useState, useCallback } from 'react';
export default function useExpanded(list = []) {
const [expanded, setExpanded] = useState([]);
const isAllExpanded = expanded.length > 0 && expanded.length === list.length;
const handleExpand = row => {
if (!row.id) {
throw new Error(`Selected row does not have an id`);
}
if (expanded.some(s => s.id === row.id)) {
setExpanded(prevState => [...prevState.filter(i => i.id !== row.id)]);
} else {
setExpanded(prevState => [...prevState, row]);
}
};
const expandAll = useCallback(
isExpanded => {
setExpanded(isExpanded ? [...list] : []);
},
[list]
);
return {
expanded,
isAllExpanded,
handleExpand,
setExpanded,
expandAll,
};
}

View File

@ -0,0 +1,116 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mount } from 'enzyme';
import useExpanded from './useExpanded';
const array = [{ id: '1' }, { id: '2' }, { id: '3' }];
const TestHook = ({ callback }) => {
callback();
return null;
};
const testHook = callback => {
mount(<TestHook callback={callback} />);
};
describe('useSelected hook', () => {
let expanded;
let isAllExpanded;
let handleExpand;
let setExpanded;
let expandAll;
test('should return expected initial values', () => {
testHook(() => {
({
expanded,
isAllExpanded,
handleExpand,
setExpanded,
expandAll,
} = useExpanded());
});
expect(expanded).toEqual([]);
expect(isAllExpanded).toEqual(false);
expect(handleExpand).toBeInstanceOf(Function);
expect(setExpanded).toBeInstanceOf(Function);
});
test('handleSelect should update and filter selected items', () => {
testHook(() => {
({
expanded,
isAllExpanded,
handleExpand,
setExpanded,
expandAll,
} = useExpanded());
});
act(() => {
handleExpand(array[0]);
});
expect(expanded).toEqual([array[0]]);
act(() => {
handleExpand(array[0]);
});
expect(expanded).toEqual([]);
});
test('should return expected isAllSelected value', () => {
testHook(() => {
({
expanded,
isAllExpanded,
handleExpand,
setExpanded,
expandAll,
} = useExpanded(array));
});
act(() => {
handleExpand(array[0]);
});
expect(expanded).toEqual([array[0]]);
expect(isAllExpanded).toEqual(false);
act(() => {
handleExpand(array[1]);
handleExpand(array[2]);
});
expect(expanded).toEqual(array);
expect(isAllExpanded).toEqual(true);
act(() => {
setExpanded([]);
});
expect(expanded).toEqual([]);
expect(isAllExpanded).toEqual(false);
});
test('should return selectAll', () => {
testHook(() => {
({
expanded,
isAllExpanded,
handleExpand,
setExpanded,
expandAll,
} = useExpanded(array));
});
act(() => {
expandAll(true);
});
expect(isAllExpanded).toEqual(true);
expect(expanded).toEqual(array);
act(() => {
expandAll(false);
});
expect(isAllExpanded).toEqual(false);
expect(expanded).toEqual([]);
});
});