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 ( -
- - - - ), - }} - onChange={handleChange} - size="small" - {...(variant === 'outlined' - ? { variant: 'outlined', label } - : { - placeholder: label, - label: false, - hiddenLabel: true, - })} - {...rest} - /> - + +
+ + + + ), + }} + onChange={handleChange} + size="small" + {...(variant === 'outlined' + ? { variant: 'outlined', label } + : { + placeholder: label, + label: false, + hiddenLabel: true, + })} + {...rest} + /> + +
); });