Skip to content

Commit

Permalink
Merge pull request #10186 from marmelab/feat/FilterButton/Improve-UX
Browse files Browse the repository at this point in the history
feat(ra-ui-materialui): Improve FilterButton UX
  • Loading branch information
djhi committed Sep 10, 2024
2 parents 8915772 + b494e95 commit 7b5a8d5
Show file tree
Hide file tree
Showing 4 changed files with 161 additions and 37 deletions.
93 changes: 74 additions & 19 deletions packages/ra-ui-materialui/src/list/filter/FilterButton.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,35 +36,90 @@ describe('<FilterButton />', () => {

beforeAll(() => {
window.scrollTo = jest.fn();
jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterAll(() => {
jest.clearAllMocks();
});

describe('filter selection menu', () => {
it('should display only hidden filters', () => {
const hiddenFilter = (
<TextInput source="Returned" label="Returned" />
);
const { getByLabelText, queryByText } = render(
<AdminContext theme={theme}>
<ResourceContextProvider value="posts">
<ListContextProvider value={defaultListContext}>
<FilterButton
filters={defaultProps.filters.concat(
hiddenFilter
)}
/>
</ListContextProvider>
</ResourceContextProvider>
</AdminContext>
it('should control filters display by checking/unchecking them in the menu', async () => {
render(<Basic />);

fireEvent.click(await screen.findByLabelText('Add filter'));

let checkboxs: HTMLInputElement[] = screen.getAllByRole('checkbox');
expect(checkboxs).toHaveLength(3);
expect(checkboxs[0].checked).toBe(false);
expect(checkboxs[1].checked).toBe(false);
expect(checkboxs[2].checked).toBe(false);

fireEvent.click(checkboxs[0]);

await screen.findByRole('textbox', {
name: 'Title',
});
fireEvent.click(screen.getByLabelText('Add filter'));

checkboxs = screen.getAllByRole('checkbox');
expect(checkboxs).toHaveLength(3);
expect(checkboxs[0].checked).toBe(true);
expect(checkboxs[1].checked).toBe(false);
expect(checkboxs[2].checked).toBe(false);

fireEvent.click(checkboxs[0]);

await waitFor(
() => {
expect(
screen.queryByRole('textbox', {
name: 'Title',
})
).toBeNull();
},
{ timeout: 2000 }
);

fireEvent.click(getByLabelText('ra.action.add_filter'));
fireEvent.click(screen.getByLabelText('Add filter'));
checkboxs = screen.getAllByRole('checkbox');
expect(checkboxs).toHaveLength(3);
expect(checkboxs[0].checked).toBe(false);
expect(checkboxs[1].checked).toBe(false);
expect(checkboxs[2].checked).toBe(false);
}, 7000);

it('should remove the checked state of the menu item when removing its matching filter', async () => {
render(<Basic />);

fireEvent.click(await screen.findByLabelText('Add filter'));

let checkboxs: HTMLInputElement[] = screen.getAllByRole('checkbox');
fireEvent.click(checkboxs[0]);

await screen.findByRole('textbox', {
name: 'Title',
});

fireEvent.click(screen.getByTitle('Remove this filter'));

await waitFor(
() => {
expect(
screen.queryByRole('textbox', {
name: 'Title',
})
).toBeNull();
},
{ timeout: 2000 }
);

expect(queryByText('Returned')).not.toBeNull();
expect(queryByText('Name')).toBeNull();
fireEvent.click(screen.getByLabelText('Add filter'));
checkboxs = screen.getAllByRole('checkbox');
expect(checkboxs).toHaveLength(3);
expect(checkboxs[0].checked).toBe(false);
expect(checkboxs[1].checked).toBe(false);
expect(checkboxs[2].checked).toBe(false);
});

it('should display the filter button if all filters are shown and there is a filter value', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export const Basic = (args: { disableSaveQuery?: boolean }) => {
defaultValue="Accusantium qui nihil voluptatum quia voluptas maxime ab similique"
/>,
<TextInput label="Nested" source="nested.foo" defaultValue="bar" />,
<TextInput label="Body" source="body" />,
];
return (
<TestMemoryRouter>
Expand Down
82 changes: 66 additions & 16 deletions packages/ra-ui-materialui/src/list/filter/FilterButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import {
MenuItem,
styled,
ButtonProps as MuiButtonProps,
Divider,
ListItemIcon,
Checkbox,
} from '@mui/material';
import ContentSave from '@mui/icons-material/Save';
import ClearIcon from '@mui/icons-material/Clear';
import ContentFilter from '@mui/icons-material/FilterList';
import lodashGet from 'lodash/get';
import isEqual from 'lodash/isEqual';
Expand Down Expand Up @@ -52,6 +57,7 @@ export const FilterButton = (props: FilterButtonProps) => {
perPage,
setFilters,
showFilter,
hideFilter,
sort,
} = useListContext();
const hasFilterValues = !isEqual(filterValues, {});
Expand All @@ -73,14 +79,19 @@ export const FilterButton = (props: FilterButtonProps) => {
);
}

const hiddenFilters = filters.filter(
(filterElement: JSX.Element) =>
!filterElement.props.alwaysOn &&
!displayedFilters[filterElement.props.source] &&
typeof lodashGet(filterValues, filterElement.props.source) ===
'undefined'
const allTogglableFilters = filters.filter(
(filterElement: JSX.Element) => !filterElement.props.alwaysOn
);

const appliedFilters = allTogglableFilters
.filter(
(filterElement: JSX.Element) =>
!!displayedFilters[filterElement.props.source] &&
typeof lodashGet(filterValues, filterElement.props.source) !==
'undefined'
)
.map((filterElement: JSX.Element) => filterElement.props.source);

const handleClickButton = useCallback(
event => {
// This prevents ghost click.
Expand Down Expand Up @@ -113,6 +124,14 @@ export const FilterButton = (props: FilterButtonProps) => {
[showFilter, setOpen]
);

const handleRemove = useCallback(
({ source }) => {
hideFilter(source);
setOpen(false);
},
[hideFilter, setOpen]
);

// add query dialog state
const [addSavedQueryDialogOpen, setAddSavedQueryDialogOpen] =
useState(false);
Expand All @@ -136,7 +155,7 @@ export const FilterButton = (props: FilterButtonProps) => {
};

if (
hiddenFilters.length === 0 &&
allTogglableFilters.length === 0 &&
validSavedQueries.length === 0 &&
!hasFilterValues
) {
Expand All @@ -159,15 +178,26 @@ export const FilterButton = (props: FilterButtonProps) => {
anchorEl={anchorEl.current}
onClose={handleRequestClose}
>
{hiddenFilters.map((filterElement: JSX.Element, index) => (
<FilterButtonMenuItem
key={filterElement.props.source}
filter={filterElement}
resource={resource}
onShow={handleShow}
autoFocus={index === 0}
/>
))}
{allTogglableFilters.map(
(filterElement: JSX.Element, index) => (
<FilterButtonMenuItem
key={filterElement.props.source}
filter={{
...filterElement,
props: {
...filterElement.props,
applied: appliedFilters.includes(
filterElement.props.source
),
},
}}
resource={resource}
onShow={handleShow}
onHide={handleRemove}
autoFocus={index === 0}
/>
)
)}
{validSavedQueries.map((savedQuery, index) =>
isEqual(savedQuery.value, {
filter: filterValues,
Expand All @@ -179,6 +209,9 @@ export const FilterButton = (props: FilterButtonProps) => {
onClick={showRemoveSavedQueryDialog}
key={index}
>
<ListItemIcon>
<ClearIcon fontSize="small" />
</ListItemIcon>
{translate(
'ra.saved_queries.remove_label_with_name',
{
Expand Down Expand Up @@ -208,14 +241,28 @@ export const FilterButton = (props: FilterButtonProps) => {
}}
key={index}
>
<Checkbox
size="small"
sx={{
paddingLeft: 0,
paddingTop: 0,
paddingBottom: 0,
marginLeft: 0,
marginRight: '7px',
}}
/>
{savedQuery.label}
</MenuItem>
)
)}
{hasFilterValues && <Divider />}
{hasFilterValues &&
!hasSavedCurrentQuery &&
!disableSaveQuery && (
<MenuItem onClick={showAddSavedQueryDialog}>
<ListItemIcon>
<ContentSave fontSize="small" />
</ListItemIcon>
{translate('ra.saved_queries.new_label', {
_: 'Save current query...',
})}
Expand All @@ -228,6 +275,9 @@ export const FilterButton = (props: FilterButtonProps) => {
setOpen(false);
}}
>
<ListItemIcon>
<ClearIcon fontSize="small" />
</ListItemIcon>
{translate('ra.action.remove_all_filters', {
_: 'Remove all filters',
})}
Expand Down
22 changes: 20 additions & 2 deletions packages/ra-ui-materialui/src/list/filter/FilterButtonMenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,46 @@ import * as React from 'react';
import { forwardRef, useCallback } from 'react';
import MenuItem from '@mui/material/MenuItem';
import { FieldTitle, useResourceContext } from 'ra-core';
import { Checkbox } from '@mui/material';

export const FilterButtonMenuItem = forwardRef<any, FilterButtonMenuItemProps>(
(props, ref) => {
const { filter, onShow, autoFocus } = props;
const { filter, onShow, onHide, autoFocus } = props;
const resource = useResourceContext(props);
const handleShow = useCallback(() => {
onShow({
source: filter.props.source,
defaultValue: filter.props.defaultValue,
});
}, [filter.props.defaultValue, filter.props.source, onShow]);
const handleHide = useCallback(() => {
onHide({
source: filter.props.source,
});
}, [filter.props.source, onHide]);

return (
<MenuItem
className="new-filter-item"
data-key={filter.props.source}
data-default-value={filter.props.defaultValue}
key={filter.props.source}
onClick={handleShow}
onClick={filter.props.applied ? handleHide : handleShow}
autoFocus={autoFocus}
ref={ref}
disabled={filter.props.disabled}
>
<Checkbox
size="small"
sx={{
paddingLeft: 0,
paddingTop: 0,
paddingBottom: 0,
marginLeft: 0,
marginRight: '7px',
}}
defaultChecked={filter.props.applied}
/>
<FieldTitle
label={filter.props.label}
source={filter.props.source}
Expand All @@ -38,6 +55,7 @@ export const FilterButtonMenuItem = forwardRef<any, FilterButtonMenuItemProps>(
export interface FilterButtonMenuItemProps {
filter: JSX.Element;
onShow: (params: { source: string; defaultValue: any }) => void;
onHide: (params: { source: string }) => void;
resource?: string;
autoFocus?: boolean;
}

0 comments on commit 7b5a8d5

Please sign in to comment.