diff --git a/docs/useList.md b/docs/useList.md
index c19b37bd950..9611b6ea5a1 100644
--- a/docs/useList.md
+++ b/docs/useList.md
@@ -103,7 +103,19 @@ const { data, total } = useList({
// data will be [{ id: 1, name: 'Arnold' }] and total will be 1
```
-The filtering capabilities are very limited. For instance, there is no "greater than" or "less than" operator. You can only filter on the equality of a field.
+The filtering capabilities are very limited. A filter on a field is a simple string comparison. There is no "greater than" or "less than" operator. You can do a full-text filter by using the `q` filter.
+
+```jsx
+const { data, total } = useList({
+ data: [
+ { id: 1, name: 'Arnold' },
+ { id: 2, name: 'Sylvester' },
+ { id: 3, name: 'Jean-Claude' },
+ ],
+ filter: { q: 'arno' },
+});
+// data will be [{ id: 1, name: 'Arnold' }] and total will be 1
+```
## `filterCallback`
diff --git a/packages/ra-core/src/controller/list/useList.spec.tsx b/packages/ra-core/src/controller/list/useList.spec.tsx
index d1388f16706..f5404a9bcc5 100644
--- a/packages/ra-core/src/controller/list/useList.spec.tsx
+++ b/packages/ra-core/src/controller/list/useList.spec.tsx
@@ -21,70 +21,6 @@ const UseList = ({
};
describe('', () => {
- it('should filter string data based on the filter props', () => {
- const callback = jest.fn();
- const data = [
- { id: 1, title: 'hello' },
- { id: 2, title: 'world' },
- ];
-
- render(
-
- );
-
- expect(callback).toHaveBeenCalledWith(
- expect.objectContaining({
- sort: { field: 'id', order: 'ASC' },
- isFetching: false,
- isLoading: false,
- data: [{ id: 2, title: 'world' }],
- error: undefined,
- total: 1,
- })
- );
- });
-
- it('should filter array data based on the filter props', async () => {
- const callback = jest.fn();
- const data = [
- { id: 1, items: ['one', 'two'] },
- { id: 2, items: ['three'] },
- { id: 3, items: 'four' },
- { id: 4, items: ['five'] },
- ];
-
- render(
-
- );
-
- await waitFor(() => {
- expect(callback).toHaveBeenCalledWith(
- expect.objectContaining({
- sort: { field: 'id', order: 'ASC' },
- isFetching: false,
- isLoading: false,
- data: [
- { id: 1, items: ['one', 'two'] },
- { id: 3, items: 'four' },
- { id: 4, items: ['five'] },
- ],
- error: undefined,
- total: 3,
- })
- );
- });
- });
-
it('should apply sorting correctly', async () => {
const callback = jest.fn();
const data = [
@@ -229,66 +165,154 @@ describe('', () => {
);
});
- it('should filter array data based on the custom filter', async () => {
- const callback = jest.fn();
- const data = [
- { id: 1, items: ['one', 'two'] },
- { id: 2, items: ['three'] },
- { id: 3, items: 'four' },
- { id: 4, items: ['five'] },
- ];
+ describe('filter', () => {
+ it('should filter string data based on the filter props', () => {
+ const callback = jest.fn();
+ const data = [
+ { id: 1, title: 'hello' },
+ { id: 2, title: 'world' },
+ ];
- render(
- record.id > 2}
- callback={callback}
- />
- );
+ render(
+
+ );
- await waitFor(() => {
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
sort: { field: 'id', order: 'ASC' },
isFetching: false,
isLoading: false,
- data: [
- { id: 3, items: 'four' },
- { id: 4, items: ['five'] },
- ],
+ data: [{ id: 2, title: 'world' }],
error: undefined,
- total: 2,
+ total: 1,
})
);
});
- });
- it('should filter data based on a custom filter with nested objects', () => {
- const callback = jest.fn();
- const data = [
- { id: 1, title: { name: 'hello' } },
- { id: 2, title: { name: 'world' } },
- ];
+ it('should filter array data based on the filter props', async () => {
+ const callback = jest.fn();
+ const data = [
+ { id: 1, items: ['one', 'two'] },
+ { id: 2, items: ['three'] },
+ { id: 3, items: 'four' },
+ { id: 4, items: ['five'] },
+ ];
- render(
-
- );
+ render(
+
+ );
- expect(callback).toHaveBeenCalledWith(
- expect.objectContaining({
- sort: { field: 'id', order: 'ASC' },
- isFetching: false,
- isLoading: false,
- data: [{ id: 2, title: { name: 'world' } }],
- error: undefined,
- total: 1,
- })
- );
+ await waitFor(() => {
+ expect(callback).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sort: { field: 'id', order: 'ASC' },
+ isFetching: false,
+ isLoading: false,
+ data: [
+ { id: 1, items: ['one', 'two'] },
+ { id: 3, items: 'four' },
+ { id: 4, items: ['five'] },
+ ],
+ error: undefined,
+ total: 3,
+ })
+ );
+ });
+ });
+
+ it('should filter array data based on the custom filter', async () => {
+ const callback = jest.fn();
+ const data = [
+ { id: 1, items: ['one', 'two'] },
+ { id: 2, items: ['three'] },
+ { id: 3, items: 'four' },
+ { id: 4, items: ['five'] },
+ ];
+
+ render(
+ record.id > 2}
+ callback={callback}
+ />
+ );
+
+ await waitFor(() => {
+ expect(callback).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sort: { field: 'id', order: 'ASC' },
+ isFetching: false,
+ isLoading: false,
+ data: [
+ { id: 3, items: 'four' },
+ { id: 4, items: ['five'] },
+ ],
+ error: undefined,
+ total: 2,
+ })
+ );
+ });
+ });
+
+ it('should filter data based on a custom filter with nested objects', () => {
+ const callback = jest.fn();
+ const data = [
+ { id: 1, title: { name: 'hello' } },
+ { id: 2, title: { name: 'world' } },
+ ];
+
+ render(
+
+ );
+
+ expect(callback).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sort: { field: 'id', order: 'ASC' },
+ isFetching: false,
+ isLoading: false,
+ data: [{ id: 2, title: { name: 'world' } }],
+ error: undefined,
+ total: 1,
+ })
+ );
+ });
+
+ it('should apply the q filter as a full-text filter', () => {
+ const callback = jest.fn();
+ const data = [
+ { id: 1, title: 'Abc', author: 'Def' }, // matches 'ab'
+ { id: 2, title: 'Ghi', author: 'Jkl' }, // does not match 'ab'
+ { id: 3, title: 'Mno', author: 'Abc' }, // matches 'ab'
+ ];
+
+ render(
+
+ );
+
+ expect(callback).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: [
+ { id: 1, title: 'Abc', author: 'Def' },
+ { id: 3, title: 'Mno', author: 'Abc' },
+ ],
+ })
+ );
+ });
});
});
diff --git a/packages/ra-core/src/controller/list/useList.ts b/packages/ra-core/src/controller/list/useList.ts
index f6ff18714a4..9edeb317010 100644
--- a/packages/ra-core/src/controller/list/useList.ts
+++ b/packages/ra-core/src/controller/list/useList.ts
@@ -178,6 +178,16 @@ export const useList = (
: recordValue.includes(filterValue)
: Array.isArray(filterValue)
? filterValue.includes(recordValue)
+ : filterName === 'q' // special full-text filter
+ ? Object.keys(record).some(
+ key =>
+ typeof record[key] === 'string' &&
+ record[key]
+ .toLowerCase()
+ .includes(
+ (filterValue as string).toLowerCase()
+ )
+ )
: filterValue == recordValue; // eslint-disable-line eqeqeq
return result;
}
diff --git a/packages/ra-ui-materialui/src/list/filter/FilterLiveSearch.spec.tsx b/packages/ra-ui-materialui/src/list/filter/FilterLiveSearch.spec.tsx
new file mode 100644
index 00000000000..af21ba12d76
--- /dev/null
+++ b/packages/ra-ui-materialui/src/list/filter/FilterLiveSearch.spec.tsx
@@ -0,0 +1,35 @@
+import * as React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+
+import { Basic } from './FilterLiveSearch.stories';
+
+describe('FilterLiveSearch', () => {
+ it('renders an empty text input', () => {
+ render();
+ expect(
+ screen.getByPlaceholderText('ra.action.search').getAttribute('type')
+ ).toBe('text');
+ expect(
+ screen
+ .getByPlaceholderText('ra.action.search')
+ .getAttribute('value')
+ ).toBe('');
+ });
+ it('filters the list when typing', () => {
+ render();
+ expect(screen.queryAllByRole('listitem')).toHaveLength(27);
+ fireEvent.change(screen.getByPlaceholderText('ra.action.search'), {
+ target: { value: 'st' },
+ });
+ expect(screen.queryAllByRole('listitem')).toHaveLength(2); // Austria and Estonia
+ });
+ it('clears the filter when user click on the reset button', () => {
+ render();
+ fireEvent.change(screen.getByPlaceholderText('ra.action.search'), {
+ target: { value: 'st' },
+ });
+ expect(screen.queryAllByRole('listitem')).toHaveLength(2);
+ fireEvent.click(screen.getByLabelText('ra.action.clear_input_value'));
+ expect(screen.queryAllByRole('listitem')).toHaveLength(27);
+ });
+});
diff --git a/packages/ra-ui-materialui/src/list/filter/FilterLiveSearch.stories.tsx b/packages/ra-ui-materialui/src/list/filter/FilterLiveSearch.stories.tsx
new file mode 100644
index 00000000000..e70479c4c11
--- /dev/null
+++ b/packages/ra-ui-materialui/src/list/filter/FilterLiveSearch.stories.tsx
@@ -0,0 +1,103 @@
+import * as React from 'react';
+import { useList, ListContextProvider, useListContext } from 'ra-core';
+import {
+ Box,
+ List,
+ ListItem,
+ ListItemText,
+ ThemeProvider,
+ createTheme,
+} from '@mui/material';
+
+import { FilterLiveSearch } from './FilterLiveSearch';
+import { defaultTheme } from '../../defaultTheme';
+
+export default {
+ title: 'ra-ui-materialui/list/filter/FilterLiveSearch',
+};
+
+const countries = [
+ { id: 1, name: 'Austria' },
+ { id: 2, name: 'Belgium' },
+ { id: 3, name: 'Bulgaria' },
+ { id: 4, name: 'Croatia' },
+ { id: 5, name: 'Republic of Cyprus' },
+ { id: 6, name: 'Czech Republic' },
+ { id: 7, name: 'Denmark' },
+ { id: 8, name: 'Estonia' },
+ { id: 9, name: 'Finland' },
+ { id: 10, name: 'France' },
+ { id: 11, name: 'Germany' },
+ { id: 12, name: 'Greece' },
+ { id: 13, name: 'Hungary' },
+ { id: 14, name: 'Ireland' },
+ { id: 15, name: 'Italy' },
+ { id: 16, name: 'Latvia' },
+ { id: 17, name: 'Lithuania' },
+ { id: 18, name: 'Luxembourg' },
+ { id: 19, name: 'Malta' },
+ { id: 20, name: 'Netherlands' },
+ { id: 21, name: 'Poland' },
+ { id: 22, name: 'Portugal' },
+ { id: 23, name: 'Romania' },
+ { id: 24, name: 'Slovakia' },
+ { id: 25, name: 'Slovenia' },
+ { id: 26, name: 'Spain' },
+ { id: 27, name: 'Sweden' },
+];
+
+const Wrapper = ({ children }) => (
+
+
+ {children}
+
+
+);
+
+const CountryList = () => {
+ const { data } = useListContext();
+ return (
+
+ {data.map(record => (
+
+ {record.name}
+
+ ))}
+
+ );
+};
+
+export const Basic = () => (
+
+
+
+
+);
+
+export const Label = () => (
+
+
+
+
+);
+
+export const Variant = () => (
+
+
+
+
+);
+
+export const FullWidth = () => (
+
+
+
+
+);
+
+export const Sx = () => (
+
+
+
+
+);
diff --git a/packages/ra-ui-materialui/src/list/filter/FilterLiveSearch.tsx b/packages/ra-ui-materialui/src/list/filter/FilterLiveSearch.tsx
index 64ee89c4768..95565424c04 100644
--- a/packages/ra-ui-materialui/src/list/filter/FilterLiveSearch.tsx
+++ b/packages/ra-ui-materialui/src/list/filter/FilterLiveSearch.tsx
@@ -3,7 +3,8 @@ import { ChangeEvent, memo, useMemo } from 'react';
import { InputAdornment } from '@mui/material';
import { SxProps } from '@mui/system';
import SearchIcon from '@mui/icons-material/Search';
-import { Form, useTranslate, useListFilterContext } from 'ra-core';
+import { useTranslate, useListFilterContext } from 'ra-core';
+import { FormProvider, useForm } from 'react-hook-form';
import { TextInput, TextInputProps } from '../../input';
@@ -38,7 +39,7 @@ export const FilterLiveSearch = memo((props: FilterLiveSearchProps) => {
setFilters({ ...filterValues, [source]: event.target.value }, null);
} else {
const { [source]: _, ...filters } = filterValues;
- setFilters(filters, null);
+ setFilters(filters, null, false);
}
};
@@ -49,32 +50,36 @@ export const FilterLiveSearch = memo((props: FilterLiveSearchProps) => {
[filterValues, source]
);
+ const form = useForm({ defaultValues: initialValues });
+
const onSubmit = () => undefined;
return (
-
+
+
+
);
});