diff --git a/packages/ra-core/src/controller/list/useRecordSelection.spec.tsx b/packages/ra-core/src/controller/list/useRecordSelection.spec.tsx index 5908f625458..d1acdf0979c 100644 --- a/packages/ra-core/src/controller/list/useRecordSelection.spec.tsx +++ b/packages/ra-core/src/controller/list/useRecordSelection.spec.tsx @@ -33,47 +33,102 @@ describe('useRecordSelection', () => { expect(selected).toEqual([123, 456]); }); - it('should allow to select/unselect a record', () => { - const { result } = renderHook(() => useRecordSelection('foo'), { - wrapper, + describe('select', () => { + it('should allow to select a record', () => { + const { result } = renderHook(() => useRecordSelection('foo'), { + wrapper, + }); + const [selected1, { select }] = result.current; + expect(selected1).toEqual([]); + select([123, 456]); + const [selected2] = result.current; + expect(selected2).toEqual([123, 456]); + }); + it('should ignore previous selection', () => { + const { result } = renderHook(() => useRecordSelection('foo'), { + wrapper, + }); + const [selected1, { select }] = result.current; + expect(selected1).toEqual([]); + select([123, 456]); + const [selected2] = result.current; + expect(selected2).toEqual([123, 456]); + select([123, 789]); + const [selected3] = result.current; + expect(selected3).toEqual([123, 789]); }); - const [selected1, { select }] = result.current; - expect(selected1).toEqual([]); - select([123, 456]); - const [selected2, { unselect }] = result.current; - expect(selected2).toEqual([123, 456]); - unselect([123]); - const [selected3] = result.current; - expect(selected3).toEqual([456]); }); - - it('should allow to toggle a record', () => { - const { result } = renderHook(() => useRecordSelection('foo'), { - wrapper, + describe('unselect', () => { + it('should allow to unselect a record', () => { + const { result } = renderHook(() => useRecordSelection('foo'), { + wrapper, + }); + const [, { select, unselect }] = result.current; + select([123, 456]); + unselect([123]); + const [selected] = result.current; + expect(selected).toEqual([456]); + }); + it('should not fail if the record was not selected', () => { + const { result } = renderHook(() => useRecordSelection('foo'), { + wrapper, + }); + const [, { select, unselect }] = result.current; + select([123, 456]); + unselect([789]); + const [selected] = result.current; + expect(selected).toEqual([123, 456]); }); - const [selected1, { toggle }] = result.current; - expect(selected1).toEqual([]); - toggle(123); - const [selected2] = result.current; - expect(selected2).toEqual([123]); - toggle(456); - const [selected3] = result.current; - expect(selected3).toEqual([123, 456]); - toggle(123); - const [selected4] = result.current; - expect(selected4).toEqual([456]); }); - - it('should allow to clear the selection', () => { - const { result } = renderHook(() => useRecordSelection('foo'), { - wrapper, + describe('toggle', () => { + it('should allow to toggle a record selection', () => { + const { result } = renderHook(() => useRecordSelection('foo'), { + wrapper, + }); + const [selected1, { toggle }] = result.current; + expect(selected1).toEqual([]); + toggle(123); + const [selected2] = result.current; + expect(selected2).toEqual([123]); + toggle(456); + const [selected3] = result.current; + expect(selected3).toEqual([123, 456]); + toggle(123); + const [selected4] = result.current; + expect(selected4).toEqual([456]); + }); + it('should allow to empty the selection', () => { + const { result } = renderHook(() => useRecordSelection('foo'), { + wrapper, + }); + const [, { select, toggle }] = result.current; + select([123]); + toggle(123); + const [selected] = result.current; + expect(selected).toEqual([]); + }); + }); + describe('clearSelection', () => { + it('should allow to clear the selection', () => { + const { result } = renderHook(() => useRecordSelection('foo'), { + wrapper, + }); + const [, { toggle, clearSelection }] = result.current; + toggle(123); + const [selected2] = result.current; + expect(selected2).toEqual([123]); + clearSelection(); + const [selected3] = result.current; + expect(selected3).toEqual([]); + }); + it('should not fail on empty selection', () => { + const { result } = renderHook(() => useRecordSelection('foo'), { + wrapper, + }); + const [, { clearSelection }] = result.current; + clearSelection(); + const [selected] = result.current; + expect(selected).toEqual([]); }); - const [, { toggle, clearSelection }] = result.current; - toggle(123); - const [selected2] = result.current; - expect(selected2).toEqual([123]); - clearSelection(); - const [selected3] = result.current; - expect(selected3).toEqual([]); }); }); diff --git a/packages/ra-core/src/controller/list/useRecordSelection.ts b/packages/ra-core/src/controller/list/useRecordSelection.ts index 46816922f6f..e6fadba1736 100644 --- a/packages/ra-core/src/controller/list/useRecordSelection.ts +++ b/packages/ra-core/src/controller/list/useRecordSelection.ts @@ -27,9 +27,10 @@ export const useRecordSelection = ( const selectionModifiers = useMemo( () => ({ - select: (idsToAdd: RecordType['id'][]) => { - if (!idsToAdd) return; - setIds([...idsToAdd]); + // erase the selection and replace it with the new one + select: (ids: RecordType['id'][]) => { + if (!ids) return; + setIds([...ids]); }, unselect(idsToRemove: RecordType['id'][]) { if (!idsToRemove || idsToRemove.length === 0) return; diff --git a/packages/ra-ui-materialui/src/list/BulkActionsToolbar.tsx b/packages/ra-ui-materialui/src/list/BulkActionsToolbar.tsx index 65e2c9de178..e2022d3fe05 100644 --- a/packages/ra-ui-materialui/src/list/BulkActionsToolbar.tsx +++ b/packages/ra-ui-materialui/src/list/BulkActionsToolbar.tsx @@ -7,14 +7,17 @@ import { useCallback, } from 'react'; import PropTypes from 'prop-types'; -import { styled } from '@mui/material/styles'; import clsx from 'clsx'; +import isEqual from 'lodash/isEqual'; +import { styled } from '@mui/material/styles'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { lighten } from '@mui/material/styles'; import IconButton from '@mui/material/IconButton'; import CloseIcon from '@mui/icons-material/Close'; +import { useMutation } from 'react-query'; import { + useDataProvider, useTranslate, sanitizeListRestProps, useListContext, @@ -22,20 +25,34 @@ import { } from 'ra-core'; import TopToolbar from '../layout/TopToolbar'; +import { Button } from '@mui/material'; export const BulkActionsToolbar = (props: BulkActionsToolbarProps) => { const { label = 'ra.action.bulk_actions', children, className, + selectAllLimit = 250, ...rest } = props; const { + data, filterValues, resource, selectedIds = [], + onSelect, onUnselectItems, + total, + perPage, } = useListContext(props); + const dataProvider = useDataProvider(); + const { mutateAsync } = useMutation(() => + dataProvider.getList(resource, { + filter: filterValues, + pagination: { page: 1, perPage: total }, + sort: { field: 'id', order: 'ASC' }, + }) + ); const translate = useTranslate(); @@ -43,6 +60,20 @@ export const BulkActionsToolbar = (props: BulkActionsToolbarProps) => { onUnselectItems(); }, [onUnselectItems]); + const handleSelectAll = useCallback(() => { + mutateAsync().then(({ data }) => { + onSelect(data.map(({ id }) => id)); + }); + }, [mutateAsync, onSelect]); + + const isPageSelected = + selectedIds.length === perPage && + isEqual(new Set(selectedIds), new Set(data.map(({ id }) => id))); + const hasMoreThanOnePage = total > perPage; + const isUnderSelectAllLimit = total <= selectAllLimit; + const displaySelectAllButton = + isPageSelected && hasMoreThanOnePage && isUnderSelectAllLimit; + return ( { smart_count: selectedIds.length, })} + {displaySelectAllButton && ( + + )} {Children.map(children, child => @@ -96,6 +137,7 @@ export interface BulkActionsToolbarProps { label?: string; selectedIds?: Identifier[]; className?: string; + selectAllLimit?: number; } const PREFIX = 'RaBulkActionsToolbar';