Skip to content

Commit

Permalink
Merge pull request #8676 from marmelab/allow-filter-list-item-customi…
Browse files Browse the repository at this point in the history
…zation

Allow to customize how `<FilterListItem>` applies filters
  • Loading branch information
slax57 authored Feb 24, 2023
2 parents 0174bbb + 22844ae commit 78a3567
Show file tree
Hide file tree
Showing 5 changed files with 235 additions and 42 deletions.
64 changes: 64 additions & 0 deletions docs/FilterList.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,67 @@ const CustomerList = props => (
{% endraw %}

**Tip**: The `<FilterList>` 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 `<FilterList>` 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 `<FilterListItem>` 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 (
<FilterList label="Categories" icon={<CategoryIcon />}>
<FilterListItem
label="Tests"
value={{ category: 'tests' }}
isSelected={isSelected}
toggleFilter={toggleFilter}
/>
<FilterListItem
label="News"
value={{ category: 'news' }}
isSelected={isSelected}
toggleFilter={toggleFilter}
/>
<FilterListItem
label="Deals"
value={{ category: 'deals' }}
isSelected={isSelected}
toggleFilter={toggleFilter}
/>
<FilterListItem
label="Tutorials"
value={{ category: 'tutorials' }}
isSelected={isSelected}
toggleFilter={toggleFilter}
/>
</FilterList>
)
}
```

![Cumulative filter list items](./img/filter-list-cumulative.gif)
Binary file added docs/img/filter-list-cumulative.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
71 changes: 71 additions & 0 deletions packages/ra-ui-materialui/src/list/filter/FilterList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ListContextProvider value={listContext}>
<Card
sx={{
width: '17em',
margin: '1em',
}}
>
<CardContent>
<FilterList label="Categories" icon={<CategoryIcon />}>
<FilterListItem
label="Tests"
value={{ category: 'tests' }}
isSelected={isSelected}
toggleFilter={toggleFilter}
/>
<FilterListItem
label="News"
value={{ category: 'news' }}
isSelected={isSelected}
toggleFilter={toggleFilter}
/>
<FilterListItem
label="Deals"
value={{ category: 'deals' }}
isSelected={isSelected}
toggleFilter={toggleFilter}
/>
<FilterListItem
label="Tutorials"
value={{ category: 'tutorials' }}
isSelected={isSelected}
toggleFilter={toggleFilter}
/>
</FilterList>
</CardContent>
</Card>
<FilterValue />
</ListContextProvider>
);
};

const FilterValue = () => {
const { filterValues } = useListContext();
return (
<Box sx={{ margin: '1em' }}>
<Typography>Filter values:</Typography>
<pre>{JSON.stringify(filterValues, null, 2)}</pre>
<pre style={{ display: 'none' }}>
{JSON.stringify(filterValues)}
</pre>
</Box>
);
};
Expand Down
73 changes: 56 additions & 17 deletions packages/ra-ui-materialui/src/list/filter/FilterListItem.spec.tsx
Original file line number Diff line number Diff line change
@@ -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: [],
Expand Down Expand Up @@ -32,62 +33,66 @@ const defaultListContext: ListControllerResult = {
};

describe('<FilterListItem/>', () => {
afterEach(cleanup);

it("should display the item label when it's a string", () => {
const { queryByText } = render(
render(
<ListContextProvider value={defaultListContext}>
<FilterListItem label="Foo" value={{ foo: 'bar' }} />
</ListContextProvider>
);
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(
<ListContextProvider value={defaultListContext}>
<FilterListItem
label={<span data-testid="123">Foo</span>}
value={{ foo: 'bar' }}
/>
</ListContextProvider>
);
expect(queryByTestId('123')).not.toBeNull();
expect(screen.queryByTestId('123')).not.toBeNull();
});

it('should not appear selected if filterValues is empty', () => {
const { getByText } = render(
render(
<ListContextProvider value={defaultListContext}>
<FilterListItem label="Foo" value={{ foo: 'bar' }} />
</ListContextProvider>
);
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(
<ListContextProvider
value={{ ...defaultListContext, filterValues: { bar: 'baz' } }}
>
<FilterListItem label="Foo" value={{ foo: 'bar' }} />
</ListContextProvider>
);
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(
<ListContextProvider
value={{ ...defaultListContext, filterValues: { foo: 'bar' } }}
>
<FilterListItem label="Foo" value={{ foo: 'bar' }} />
</ListContextProvider>
);
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(
<ListContextProvider
value={{
...defaultListContext,
Expand Down Expand Up @@ -126,11 +131,13 @@ describe('<FilterListItem/>', () => {
/>
</ListContextProvider>
);
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(
<ListContextProvider
value={{
...defaultListContext,
Expand All @@ -140,6 +147,38 @@ describe('<FilterListItem/>', () => {
<FilterListItem label="Foo" value={{ foo: 'bar' }} />
</ListContextProvider>
);
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(<Cumulative />);

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<HTMLElement>('[data-selected="true"]')
).map(item => item.textContent);
Loading

0 comments on commit 78a3567

Please sign in to comment.