diff --git a/packages/ra-core/src/dataTable/DataTableColumnFilterContext.ts b/packages/ra-core/src/dataTable/DataTableColumnFilterContext.ts new file mode 100644 index 00000000000..7930289105a --- /dev/null +++ b/packages/ra-core/src/dataTable/DataTableColumnFilterContext.ts @@ -0,0 +1,8 @@ +import { createContext, useContext } from 'react'; + +export const DataTableColumnFilterContext = createContext( + undefined +); + +export const useDataTableColumnFilterContext = () => + useContext(DataTableColumnFilterContext); diff --git a/packages/ra-core/src/dataTable/index.ts b/packages/ra-core/src/dataTable/index.ts index 2705282a1fa..74321a54bf0 100644 --- a/packages/ra-core/src/dataTable/index.ts +++ b/packages/ra-core/src/dataTable/index.ts @@ -1,6 +1,7 @@ export * from './DataTableBase'; export * from './DataTableCallbacksContext'; export * from './DataTableColumnRankContext'; +export * from './DataTableColumnFilterContext'; export * from './DataTableConfigContext'; export * from './DataTableDataContext'; export * from './DataTableRenderContext'; diff --git a/packages/ra-core/src/i18n/TranslationMessages.ts b/packages/ra-core/src/i18n/TranslationMessages.ts index 476547f3553..085bc7d61a1 100644 --- a/packages/ra-core/src/i18n/TranslationMessages.ts +++ b/packages/ra-core/src/i18n/TranslationMessages.ts @@ -28,6 +28,7 @@ export interface TranslationMessages extends StringMap { remove: string; save: string; search: string; + search_columns: string; select_all: string; select_all_button: string; select_row: string; diff --git a/packages/ra-language-english/src/index.ts b/packages/ra-language-english/src/index.ts index 0e665685b0f..d9435902eae 100644 --- a/packages/ra-language-english/src/index.ts +++ b/packages/ra-language-english/src/index.ts @@ -24,6 +24,7 @@ const englishMessages: TranslationMessages = { remove: 'Remove', save: 'Save', search: 'Search', + search_columns: 'Search columns', select_all: 'Select all', select_all_button: 'Select all', select_row: 'Select this row', diff --git a/packages/ra-language-french/src/index.ts b/packages/ra-language-french/src/index.ts index fb450792c02..a42a2999aa2 100644 --- a/packages/ra-language-french/src/index.ts +++ b/packages/ra-language-french/src/index.ts @@ -28,6 +28,7 @@ const frenchMessages: TranslationMessages = { select_all_button: 'Tout sélectionner', select_row: 'Sélectionner cette ligne', search: 'Rechercher', + search_columns: 'Filtrer les colonnes', show: 'Afficher', sort: 'Trier', undo: 'Annuler', diff --git a/packages/ra-ui-materialui/package.json b/packages/ra-ui-materialui/package.json index 99ea6ceba69..3311bbf61c7 100644 --- a/packages/ra-ui-materialui/package.json +++ b/packages/ra-ui-materialui/package.json @@ -72,6 +72,7 @@ "autosuggest-highlight": "^3.1.1", "clsx": "^2.1.1", "css-mediaquery": "^0.1.2", + "diacritic": "^0.0.2", "dompurify": "^3.2.4", "inflection": "^3.0.0", "jsonexport": "^3.2.0", diff --git a/packages/ra-ui-materialui/src/list/datatable/ColumnsButton.spec.tsx b/packages/ra-ui-materialui/src/list/datatable/ColumnsButton.spec.tsx new file mode 100644 index 00000000000..b3aaa656139 --- /dev/null +++ b/packages/ra-ui-materialui/src/list/datatable/ColumnsButton.spec.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { Basic, FewColumns, LabelTypes } from './ColumnsButton.stories'; + +describe('ColumnsButton', () => { + it('should render one row per column unless they are hidden', async () => { + render(); + fireEvent.click(await screen.findByText('ra.action.select_columns')); + await screen.findByLabelText('c_0'); + await screen.findByLabelText('c_1'); + await screen.findByLabelText('c_2'); + await screen.findByLabelText('c_3'); + await screen.findByLabelText('c_4'); + await screen.findByLabelText('c_5'); + // await screen.findByLabelText('c_6'); // hidden + await screen.findByLabelText('c_7'); + }); + it('should not render the filter input when there are too few columns', async () => { + render(); + fireEvent.click(await screen.findByText('ra.action.select_columns')); + await screen.findByLabelText('c_0'); + expect(screen.queryByText('ra.action.search_columns')).toBeNull(); + }); + it('should render a filter input when there are many columns', async () => { + render(); + fireEvent.click(await screen.findByText('ra.action.select_columns')); + await screen.findByLabelText('resources.test.fields.col0'); + expect( + screen + .getByRole('menu') + .querySelectorAll('li:not(.columns-selector-actions)') + ).toHaveLength(7); + // Typing a filter + fireEvent.change( + screen.getByPlaceholderText('ra.action.search_columns'), + { + // filter should be case and diacritics insensitive + target: { value: 'DiA' }, + } + ); + await waitFor(() => { + expect( + screen + .getByRole('menu') + .querySelectorAll('li:not(.columns-selector-actions)') + ).toHaveLength(1); + }); + screen.getByLabelText('Téstïng diàcritics'); + // Clear the filter + fireEvent.click(screen.getByLabelText('ra.action.clear_input_value')); + await waitFor(() => { + expect( + screen + .getByRole('menu') + .querySelectorAll('li:not(.columns-selector-actions)') + ).toHaveLength(7); + }); + }); +}); diff --git a/packages/ra-ui-materialui/src/list/datatable/ColumnsButton.stories.tsx b/packages/ra-ui-materialui/src/list/datatable/ColumnsButton.stories.tsx index 5f317d91007..9aea32b05d8 100644 --- a/packages/ra-ui-materialui/src/list/datatable/ColumnsButton.stories.tsx +++ b/packages/ra-ui-materialui/src/list/datatable/ColumnsButton.stories.tsx @@ -65,3 +65,39 @@ export const Basic = () => ( ); + +export const FewColumns = () => ( + } actions={null}> + + + + + + + + +); + +export const LabelTypes = () => ( + } actions={null}> + + + + + + + Testing React Element + + } + /> + + + + + + + +); diff --git a/packages/ra-ui-materialui/src/list/datatable/ColumnsSelector.tsx b/packages/ra-ui-materialui/src/list/datatable/ColumnsSelector.tsx index 24f87ea65be..3d2a3d6369a 100644 --- a/packages/ra-ui-materialui/src/list/datatable/ColumnsSelector.tsx +++ b/packages/ra-ui-materialui/src/list/datatable/ColumnsSelector.tsx @@ -5,10 +5,14 @@ import { useStore, DataTableColumnRankContext, useDataTableStoreContext, + useTranslate, + DataTableColumnFilterContext, } from 'ra-core'; -import { Box } from '@mui/material'; +import { Box, InputAdornment } from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; import { Button } from '../../button'; +import { ResettableTextField } from '../../input/ResettableTextField'; /** * Render DataTable.Col elements in the ColumnsButton selector using a React Portal. @@ -16,6 +20,7 @@ import { Button } from '../../button'; * @see ColumnsButton */ export const ColumnsSelector = ({ children }: ColumnsSelectorProps) => { + const translate = useTranslate(); const { storeKey, defaultHiddenColumns } = useDataTableStoreContext(); const [columnRanks, setColumnRanks] = useStore( `${storeKey}_columnRanks` @@ -53,19 +58,55 @@ export const ColumnsSelector = ({ children }: ColumnsSelectorProps) => { }; }, [elementId, container]); + const [columnFilter, setColumnFilter] = React.useState(''); + if (!container) return null; const childrenArray = Children.toArray(children); const paddedColumnRanks = padRanks(columnRanks ?? [], childrenArray.length); + const shouldDisplaySearchInput = childrenArray.length > 5; return createPortal( <> + {shouldDisplaySearchInput ? ( + { + if (typeof e === 'string') { + setColumnFilter(e); + return; + } + setColumnFilter(e.target.value); + }} + placeholder={translate('ra.action.search_columns', { + _: 'Search columns', + })} + InputProps={{ + endAdornment: ( + + + + ), + }} + resettable + autoFocus + size="small" + sx={{ mb: 1 }} + /> + ) : null} {paddedColumnRanks.map((position, index) => ( - {childrenArray[position]} + + {childrenArray[position]} + ))} ( `${storeKey}_columnRanks` ); + const columnFilter = useDataTableColumnFilterContext(); const translateLabel = useTranslateLabel(); if (!source && !label) return null; const fieldLabel = translateLabel({ label: typeof label === 'string' ? label : undefined, resource, source, - }); + }) as string; const isColumnHidden = hiddenColumns.includes(source!); + const isColumnFiltered = fieldLabelMatchesFilter(fieldLabel, columnFilter); const handleMove = (index1, index2) => { const colRanks = !columnRanks @@ -69,7 +73,7 @@ export const ColumnsSelectorItem = ({ setColumnRanks(newColumnRanks); }; - return ( + return isColumnFiltered ? ( - ); + ) : null; }; const padRanks = (ranks: number[], length: number) => @@ -95,3 +99,11 @@ const padRanks = (ranks: number[], length: number) => (_, i) => ranks.length + i ) ); + +const fieldLabelMatchesFilter = (fieldLabel: string, columnFilter?: string) => + columnFilter + ? diacritic + .clean(fieldLabel) + .toLowerCase() + .includes(diacritic.clean(columnFilter).toLowerCase()) + : true; diff --git a/yarn.lock b/yarn.lock index b4c49d9c823..7ae598f58ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8499,7 +8499,7 @@ __metadata: languageName: node linkType: hard -"diacritic@npm:0.0.2": +"diacritic@npm:0.0.2, diacritic@npm:^0.0.2": version: 0.0.2 resolution: "diacritic@npm:0.0.2" checksum: 1d9dd0a1188a8186d4fce4a695fc8cb0d65c31a8b3c59cd926636e49a05b30d6bb3f4144018be40bdf0a4937d16bb6705f3b1d1ff9684a426d922fb039f8d8ae @@ -16316,6 +16316,7 @@ __metadata: cross-env: "npm:^5.2.0" css-mediaquery: "npm:^0.1.2" csstype: "npm:^3.1.3" + diacritic: "npm:^0.0.2" dompurify: "npm:^3.2.4" expect: "npm:^27.4.6" file-api: "npm:~0.10.4"