diff --git a/docs/FilterList.md b/docs/FilterList.md index 4265462d6e7..41aa665a84d 100644 --- a/docs/FilterList.md +++ b/docs/FilterList.md @@ -185,3 +185,67 @@ const CustomerList = props => ( {% endraw %} **Tip**: The `` Sidebar is not a good UI for small screens. You can choose to hide it on small screens (as in the previous example). A good tradeoff is to use `` on large screens, and the Filter Button/Form combo on Mobile. + +## Customize How Filters Are Applied + +Sometimes, you may want to customize how filters are applied. For instance, by allowing users to select multiple items such as selecting multiple categories. The `` component accepts two props for this purpose: + +- `isSelected`: accepts a function that receives the item value and the currently applied filters. It must return a boolean. +- `toggleFilter`: accepts a function that receives the item value and the currently applied filters. It is called when user toggles a filter and must return the new filters to apply. + +Here's how you could implement cumulative filters, e.g. allowing users to filter items having one of several categories: + +```jsx +import { FilterList, FilterListItem } from 'react-admin'; +import CategoryIcon from '@mui/icons-material/LocalOffer'; + +export const CategoriesFilter = () => { + const isSelected = (value, filters) => { + const category = filters.category || []; + return category.includes(value.category); + }; + + const toggleFilter = (value, filters) => { + const category = filters.category || []; + return { + ...filters, + category: category.includes(value.category) + // Remove the category if it was already present + ? category.filter(v => v !== value.category) + // Add the category if it wasn't already present + : [...category, value.category], + }; + }; + + return ( + }> + + + + + + ) +} +``` + +![Cumulative filter list items](./img/filter-list-cumulative.gif) diff --git a/docs/img/filter-list-cumulative.gif b/docs/img/filter-list-cumulative.gif new file mode 100644 index 00000000000..16494f1b34f Binary files /dev/null and b/docs/img/filter-list-cumulative.gif differ diff --git a/packages/ra-ui-materialui/src/list/filter/FilterList.stories.tsx b/packages/ra-ui-materialui/src/list/filter/FilterList.stories.tsx index 480628e0d7b..300278167ec 100644 --- a/packages/ra-ui-materialui/src/list/filter/FilterList.stories.tsx +++ b/packages/ra-ui-materialui/src/list/filter/FilterList.stories.tsx @@ -77,12 +77,83 @@ export const Basic = () => { ); }; +export const Cumulative = () => { + const listContext = useList({ + data: [ + { id: 1, title: 'Article test', category: 'tests' }, + { id: 2, title: 'Article news', category: 'news' }, + { id: 3, title: 'Article deals', category: 'deals' }, + { id: 4, title: 'Article tutorials', category: 'tutorials' }, + ], + filter: { + category: ['tutorials', 'news'], + }, + }); + const isSelected = (value, filters) => { + const category = filters.category || []; + return category.includes(value.category); + }; + + const toggleFilter = (value, filters) => { + const category = filters.category || []; + return { + ...filters, + category: category.includes(value.category) + ? category.filter(v => v !== value.category) + : [...category, value.category], + }; + }; + return ( + + + + }> + + + + + + + + + + ); +}; + const FilterValue = () => { const { filterValues } = useListContext(); return ( Filter values:
{JSON.stringify(filterValues, null, 2)}
+
+                {JSON.stringify(filterValues)}
+            
); }; diff --git a/packages/ra-ui-materialui/src/list/filter/FilterListItem.spec.tsx b/packages/ra-ui-materialui/src/list/filter/FilterListItem.spec.tsx index 85ddf678061..632008eb3e1 100644 --- a/packages/ra-ui-materialui/src/list/filter/FilterListItem.spec.tsx +++ b/packages/ra-ui-materialui/src/list/filter/FilterListItem.spec.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; import expect from 'expect'; -import { render, cleanup } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { ListContextProvider, ListControllerResult } from 'ra-core'; import { FilterListItem } from './FilterListItem'; +import { Cumulative } from './FilterList.stories'; const defaultListContext: ListControllerResult = { data: [], @@ -32,19 +33,17 @@ const defaultListContext: ListControllerResult = { }; describe('', () => { - afterEach(cleanup); - it("should display the item label when it's a string", () => { - const { queryByText } = render( + render( ); - expect(queryByText('Foo')).not.toBeNull(); + expect(screen.queryByText('Foo')).not.toBeNull(); }); it("should display the item label when it's an element", () => { - const { queryByTestId } = render( + render( Foo} @@ -52,42 +51,48 @@ describe('', () => { /> ); - expect(queryByTestId('123')).not.toBeNull(); + expect(screen.queryByTestId('123')).not.toBeNull(); }); it('should not appear selected if filterValues is empty', () => { - const { getByText } = render( + render( ); - expect(getByText('Foo').parentElement?.dataset.selected).toBe('false'); + expect(screen.getByText('Foo').parentElement?.dataset.selected).toBe( + 'false' + ); }); it('should not appear selected if filterValues does not contain value', () => { - const { getByText } = render( + render( ); - expect(getByText('Foo').parentElement?.dataset.selected).toBe('false'); + expect(screen.getByText('Foo').parentElement?.dataset.selected).toBe( + 'false' + ); }); it('should appear selected if filterValues is equal to value', () => { - const { getByText } = render( + render( ); - expect(getByText('Foo').parentElement?.dataset.selected).toBe('true'); + expect(screen.getByText('Foo').parentElement?.dataset.selected).toBe( + 'true' + ); }); it('should appear selected if filterValues is equal to value for nested filters', () => { - const { getByText } = render( + render( ', () => { /> ); - expect(getByText('Foo').parentElement?.dataset.selected).toBe('true'); + expect(screen.getByText('Foo').parentElement?.dataset.selected).toBe( + 'true' + ); }); it('should appear selected if filterValues contains value', () => { - const { getByText } = render( + render( ', () => { ); - expect(getByText('Foo').parentElement?.dataset.selected).toBe('true'); + expect(screen.getByText('Foo').parentElement?.dataset.selected).toBe( + 'true' + ); + }); + + it('should allow to customize isSelected and toggleFilter', () => { + const { container } = render(); + + expect(getSelectedItemsLabels(container)).toEqual([ + 'News', + 'Tutorials', + ]); + screen.getByText(JSON.stringify({ category: ['tutorials', 'news'] })); + + screen.getByText('News').click(); + + expect(getSelectedItemsLabels(container)).toEqual(['Tutorials']); + screen.getByText(JSON.stringify({ category: ['tutorials'] })); + + screen.getByText('Tutorials').click(); + + expect(getSelectedItemsLabels(container)).toEqual([]); + expect(screen.getAllByText(JSON.stringify({})).length).toBe(2); + + screen.getByText('Tests').click(); + + expect(getSelectedItemsLabels(container)).toEqual(['Tests']); + screen.getByText(JSON.stringify({ category: ['tests'] })); }); }); + +const getSelectedItemsLabels = (container: HTMLElement) => + Array.from( + container.querySelectorAll('[data-selected="true"]') + ).map(item => item.textContent); diff --git a/packages/ra-ui-materialui/src/list/filter/FilterListItem.tsx b/packages/ra-ui-materialui/src/list/filter/FilterListItem.tsx index 482ab6cc877..f5e9f6ff7bc 100644 --- a/packages/ra-ui-materialui/src/list/filter/FilterListItem.tsx +++ b/packages/ra-ui-materialui/src/list/filter/FilterListItem.tsx @@ -10,7 +10,12 @@ import { ListItemSecondaryAction, } from '@mui/material'; import CancelIcon from '@mui/icons-material/CancelOutlined'; -import { useTranslate, useListFilterContext, shallowEqual } from 'ra-core'; +import { + useTranslate, + useListFilterContext, + shallowEqual, + useEvent, +} from 'ra-core'; import matches from 'lodash/matches'; import pickBy from 'lodash/pickBy'; @@ -141,36 +146,26 @@ const arePropsEqual = (prevProps, nextProps) => * ); */ export const FilterListItem = memo((props: FilterListItemProps) => { - const { label, value, ...rest } = props; + const { + label, + value, + isSelected: getIsSelected = DefaultIsSelected, + toggleFilter: userToggleFilter = DefaultToggleFilter, + ...rest + } = props; const { filterValues, setFilters } = useListFilterContext(); const translate = useTranslate(); + const toggleFilter = useEvent(userToggleFilter); - const isSelected = matches( - pickBy(value, val => typeof val !== 'undefined') - )(filterValues); + // We can't wrap this function with useEvent as it is called in the render phase + const isSelected = getIsSelected(value, filterValues); - const addFilter = () => { - setFilters({ ...filterValues, ...value }, null, false); - }; - - const removeFilter = () => { - const keysToRemove = Object.keys(value); - const filters = Object.keys(filterValues).reduce( - (acc, key) => - keysToRemove.includes(key) - ? acc - : { ...acc, [key]: filterValues[key] }, - {} - ); - - setFilters(filters, null, false); - }; - - const toggleFilter = () => (isSelected ? removeFilter() : addFilter()); + const handleClick = () => + setFilters(toggleFilter(value, filterValues), null, false); return ( { { event.stopPropagation(); - toggleFilter(); + handleClick(); }} > @@ -205,6 +200,28 @@ export const FilterListItem = memo((props: FilterListItemProps) => { ); }, arePropsEqual); +const DefaultIsSelected = (value, filters) => + matches(pickBy(value, val => typeof val !== 'undefined'))(filters); + +const DefaultToggleFilter = (value, filters) => { + const isSelected = matches( + pickBy(value, val => typeof val !== 'undefined') + )(filters); + + if (isSelected) { + const keysToRemove = Object.keys(value); + return Object.keys(filters).reduce( + (acc, key) => + keysToRemove.includes(key) + ? acc + : { ...acc, [key]: filters[key] }, + {} + ); + } + + return { ...filters, ...value }; +}; + const PREFIX = 'RaFilterListItem'; export const FilterListItemClasses = { @@ -228,4 +245,6 @@ const StyledListItem = styled(ListItem, { export interface FilterListItemProps extends Omit { label: string | ReactElement; value: any; + toggleFilter?: (value: any, filters: any) => any; + isSelected?: (value: any, filters: any) => boolean; }