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

Allow to customize how <FilterListItem> applies filters #8676

Merged
merged 7 commits into from
Feb 24, 2023
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
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
slax57 marked this conversation as resolved.
Show resolved Hide resolved

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:

djhi marked this conversation as resolved.
Show resolved Hide resolved
- `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