Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFR] Add support to select records with Shift + click #5936

Merged
merged 1 commit into from
Feb 19, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions cypress/integration/list.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,25 @@ describe('List Page', () => {
ListPagePosts.applyDeleteBulkAction();
cy.contains('1-10 of 10');
});

it('should allow to select items with the shift key on different pages', () => {
cy.contains('1-10 of 13'); // wait for data
cy.get(ListPagePosts.elements.selectItem).eq(0).click();
cy.get(ListPagePosts.elements.selectItem)
.eq(2)
.click({ shiftKey: true });
cy.contains('3 items selected');
ListPagePosts.nextPage();
cy.contains('11-13 of 13'); // wait for data
cy.get(ListPagePosts.elements.selectedItem).should(els => {
expect(els).to.have.length(0);
});
cy.get(ListPagePosts.elements.selectItem).eq(0).click();
cy.get(ListPagePosts.elements.selectItem)
.eq(2)
.click({ shiftKey: true });
cy.contains('6 items selected');
});
});

describe('rowClick', () => {
Expand Down
217 changes: 217 additions & 0 deletions packages/ra-ui-materialui/src/list/datagrid/Datagrid.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import * as React from 'react';
import { fireEvent } from '@testing-library/react';
import { ListContextProvider } from 'ra-core';
import { renderWithRedux } from 'ra-test';
import Datagrid from './Datagrid';

const TitleField = ({ record }: any): JSX.Element => (
<span>{record.title}</span>
);

describe('<Datagrid />', () => {
const defaultData = {
1: { id: 1, title: 'title 1' },
2: { id: 2, title: 'title 2' },
3: { id: 3, title: 'title 3' },
4: { id: 4, title: 'title 4' },
};

const contextValue = {
resource: 'posts',
basePath: '',
data: defaultData,
ids: [1, 2, 3, 4],
loaded: true,
loading: false,
selectedIds: [],
currentSort: { field: 'title', order: 'ASC' },
onToggleItem: jest.fn(),
onSelect: jest.fn(),
};

afterEach(() => {
contextValue.onToggleItem.mockClear();
contextValue.onSelect.mockClear();
});

it('should call onToggleItem when the shift key is not pressed', () => {
const { queryAllByRole } = renderWithRedux(
<ListContextProvider value={contextValue}>
<Datagrid hasBulkActions>
<TitleField />
</Datagrid>
</ListContextProvider>
);
fireEvent.click(queryAllByRole('checkbox')[1]);
expect(contextValue.onToggleItem).toHaveBeenCalledWith(1);
expect(contextValue.onSelect).toHaveBeenCalledTimes(0);
});

describe('selecting items with the shift key', () => {
it('should call onSelect with the correct ids when the last selection is after the first', () => {
const Test = ({ selectedIds = [] }) => (
<ListContextProvider value={{ ...contextValue, selectedIds }}>
<Datagrid hasBulkActions>
<TitleField />
</Datagrid>
</ListContextProvider>
);
const { queryAllByRole, rerender } = renderWithRedux(<Test />);
const checkboxes = queryAllByRole('checkbox');
fireEvent.click(checkboxes[1]);
rerender(<Test selectedIds={[1]} />);
fireEvent.click(checkboxes[3], {
shiftKey: true,
checked: true,
});
expect(contextValue.onToggleItem).toHaveBeenCalledTimes(1);
expect(contextValue.onSelect).toHaveBeenCalledWith([1, 2, 3]);
});

it('should call onSelect with the correct ids when the last selection is before the first', () => {
const Test = ({ selectedIds = [] }) => (
<ListContextProvider value={{ ...contextValue, selectedIds }}>
<Datagrid hasBulkActions>
<TitleField />
</Datagrid>
</ListContextProvider>
);
const { queryAllByRole, rerender } = renderWithRedux(<Test />);
const checkboxes = queryAllByRole('checkbox');
fireEvent.click(checkboxes[3], { checked: true });
rerender(<Test selectedIds={[3]} />);
fireEvent.click(checkboxes[1], {
shiftKey: true,
checked: true,
});
expect(contextValue.onToggleItem).toHaveBeenCalledTimes(1);
expect(contextValue.onSelect).toHaveBeenCalledWith([3, 1, 2]);
});

it('should call onSelect with the correct ids when unselecting items', () => {
const Test = ({ selectedIds = [] }) => (
<ListContextProvider value={{ ...contextValue, selectedIds }}>
<Datagrid hasBulkActions>
<TitleField />
</Datagrid>
</ListContextProvider>
);
const { queryAllByRole, rerender } = renderWithRedux(
<Test selectedIds={[1, 2, 4]} />
);
const checkboxes = queryAllByRole('checkbox');
fireEvent.click(checkboxes[3], { checked: true });
rerender(<Test selectedIds={[1, 2, 4, 3]} />);
fireEvent.click(checkboxes[4], { shiftKey: true });
expect(contextValue.onToggleItem).toHaveBeenCalledTimes(1);
expect(contextValue.onSelect).toHaveBeenCalledWith([1, 2]);
});

it('should call onToggeItem when the last selected id is not in the ids', () => {
const Test = ({
selectedIds = [],
ids = [1, 2, 3, 4],
data = defaultData,
}: any) => (
<ListContextProvider
value={{ ...contextValue, selectedIds, ids, data }}
>
<Datagrid hasBulkActions>
<TitleField />
</Datagrid>
</ListContextProvider>
);
const { queryAllByRole, rerender } = renderWithRedux(<Test />);
fireEvent.click(queryAllByRole('checkbox')[1], { checked: true });

// Simulate page change
const newData = { 5: { id: 5, title: 'title 5' } };
rerender(<Test ids={[5]} selectedIds={[1]} data={newData} />);

fireEvent.click(queryAllByRole('checkbox')[1], {
checked: true,
shiftKey: true,
});

expect(contextValue.onToggleItem).toHaveBeenCalledTimes(2);
expect(contextValue.onSelect).toHaveBeenCalledTimes(0);
});

it('should not extend selection when selectedIds is cleared', () => {
const Test = ({ selectedIds = [] }) => (
<ListContextProvider value={{ ...contextValue, selectedIds }}>
<Datagrid hasBulkActions>
<TitleField />
</Datagrid>
</ListContextProvider>
);
const { queryAllByRole, rerender } = renderWithRedux(<Test />);
const checkboxes = queryAllByRole('checkbox');
fireEvent.click(checkboxes[1], { checked: true });
rerender(<Test selectedIds={[1]} />);

// Simulate unselecting all items
rerender(<Test />);

fireEvent.click(checkboxes[1], {
checked: true,
shiftKey: true,
});

expect(contextValue.onToggleItem).toHaveBeenCalledTimes(2);
expect(contextValue.onSelect).toHaveBeenCalledTimes(0);
});

it('should respect isRowSelectable when calling onSelect', () => {
const Test = ({ selectedIds = [] }) => (
<ListContextProvider value={{ ...contextValue, selectedIds }}>
<Datagrid
isRowSelectable={record => record.id !== 2}
hasBulkActions
>
<TitleField />
</Datagrid>
</ListContextProvider>
);
const { queryAllByRole, rerender } = renderWithRedux(<Test />);
const checkboxes = queryAllByRole('checkbox');
fireEvent.click(checkboxes[1], { checked: true });
rerender(<Test selectedIds={[1]} />);
fireEvent.click(checkboxes[2], {
shiftKey: true,
checked: true,
});
expect(contextValue.onToggleItem).toHaveBeenCalledTimes(1);
expect(contextValue.onSelect).toHaveBeenCalledWith([1, 3]);
});

it('should not use as last selected the item that was unselected', () => {
const Test = ({ selectedIds = [] }) => (
<ListContextProvider value={{ ...contextValue, selectedIds }}>
<Datagrid hasBulkActions>
<TitleField />
</Datagrid>
</ListContextProvider>
);
const { queryAllByRole, rerender } = renderWithRedux(<Test />);
const checkboxes = queryAllByRole('checkbox');
fireEvent.click(checkboxes[1], { checked: true });
expect(contextValue.onToggleItem).toHaveBeenCalledWith(1);

rerender(<Test selectedIds={[1]} />);
fireEvent.click(checkboxes[2], { shiftKey: true, checked: true });
expect(contextValue.onSelect).toHaveBeenCalledWith([1, 2]);

rerender(<Test selectedIds={[1, 2]} />);
fireEvent.click(checkboxes[2]);
expect(contextValue.onToggleItem).toHaveBeenCalledWith(2);

rerender(<Test selectedIds={[1]} />);
fireEvent.click(checkboxes[4], { shiftKey: true, checked: true });
expect(contextValue.onToggleItem).toHaveBeenCalledWith(4);

expect(contextValue.onToggleItem).toHaveBeenCalledTimes(3);
expect(contextValue.onSelect).toHaveBeenCalledTimes(1);
});
});
});
44 changes: 43 additions & 1 deletion packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
Children,
cloneElement,
useCallback,
useRef,
useEffect,
FC,
ReactElement,
} from 'react';
Expand All @@ -24,6 +26,8 @@ import {
TableRow,
} from '@material-ui/core';
import classnames from 'classnames';
import union from 'lodash/union';
import difference from 'lodash/difference';

import DatagridHeaderCell from './DatagridHeaderCell';
import DatagridLoading from './DatagridLoading';
Expand Down Expand Up @@ -167,6 +171,44 @@ const Datagrid: FC<DatagridProps> = React.forwardRef((props, ref) => {
[data, ids, onSelect, isRowSelectable, selectedIds]
);

const lastSelected = useRef(null);

useEffect(() => {
if (selectedIds.length === 0) {
lastSelected.current = null;
}
}, [selectedIds.length]);

const handleToggleItem = useCallback(
(id, event) => {
const lastSelectedIndex = ids.indexOf(lastSelected.current);
lastSelected.current = event.target.checked ? id : null;

if (event.shiftKey && lastSelectedIndex !== -1) {
const index = ids.indexOf(id);
const idsBetweenSelections = ids.slice(
Math.min(lastSelectedIndex, index),
Math.max(lastSelectedIndex, index) + 1
);

const newSelectedIds = event.target.checked
? union(selectedIds, idsBetweenSelections)
: difference(selectedIds, idsBetweenSelections);

onSelect(
isRowSelectable
? newSelectedIds.filter((id: Identifier) =>
isRowSelectable(data[id])
)
: newSelectedIds
);
} else {
onToggleItem(id);
}
},
[data, ids, isRowSelectable, onSelect, onToggleItem, selectedIds]
);

/**
* if loaded is false, the list displays for the first time, and the dataProvider hasn't answered yet
* if loaded is true, the data for the list has at least been returned once by the dataProvider
Expand Down Expand Up @@ -271,7 +313,7 @@ const Datagrid: FC<DatagridProps> = React.forwardRef((props, ref) => {
hasBulkActions,
hover,
ids,
onToggleItem,
onToggleItem: handleToggleItem,
resource,
rowStyle,
selectedIds,
Expand Down
5 changes: 4 additions & 1 deletion packages/ra-ui-materialui/src/list/datagrid/DatagridBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,10 @@ export interface DatagridBodyProps extends Omit<TableBodyProps, 'classes'> {
hasBulkActions?: boolean;
hover?: boolean;
ids?: Identifier[];
onToggleItem?: (id: Identifier) => void;
onToggleItem?: (
id: Identifier,
event: React.TouchEvent | React.MouseEvent
) => void;
record?: Record;
resource?: string;
row?: ReactElement;
Expand Down
7 changes: 5 additions & 2 deletions packages/ra-ui-materialui/src/list/datagrid/DatagridRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const DatagridRow: FC<DatagridRowProps> = React.forwardRef((props, ref) => {
const handleToggleSelection = useCallback(
event => {
if (!selectable) return;
onToggleItem(id);
onToggleItem(id, event);
event.stopPropagation();
},
[id, onToggleItem, selectable]
Expand Down Expand Up @@ -249,7 +249,10 @@ export interface DatagridRowProps
hasBulkActions?: boolean;
hover?: boolean;
id?: Identifier;
onToggleItem?: (id: Identifier) => void;
onToggleItem?: (
id: Identifier,
event: React.TouchEvent | React.MouseEvent
) => void;
record?: Record;
resource?: string;
rowClick?: RowClickFunction | string;
Expand Down