mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-04 13:48:56 +02:00
feat: keep filters ordered based on user selection (#5609)
This commit is contained in:
parent
850b78a699
commit
386c4baa86
@ -4,13 +4,14 @@ import { ArrowDropDown, Close, TopicOutlined } from '@mui/icons-material';
|
|||||||
import { ConditionallyRender } from '../../ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from '../../ConditionallyRender/ConditionallyRender';
|
||||||
import { Chip, IconButton, styled } from '@mui/material';
|
import { Chip, IconButton, styled } from '@mui/material';
|
||||||
import { FilterItemOperator } from './FilterItemOperator/FilterItemOperator';
|
import { FilterItemOperator } from './FilterItemOperator/FilterItemOperator';
|
||||||
|
import { FILTER_ITEM } from 'utils/testIds';
|
||||||
|
|
||||||
const StyledChip = styled(
|
const StyledChip = styled(
|
||||||
({
|
({
|
||||||
isActive,
|
isActive,
|
||||||
...props
|
...props
|
||||||
}: { isActive: boolean } & ComponentProps<typeof Chip>) => (
|
}: { isActive: boolean } & ComponentProps<typeof Chip>) => (
|
||||||
<Chip {...props} />
|
<Chip data-testid={FILTER_ITEM} {...props} />
|
||||||
),
|
),
|
||||||
)(({ theme, isActive = false }) => ({
|
)(({ theme, isActive = false }) => ({
|
||||||
borderRadius: `${theme.shape.borderRadius}px`,
|
borderRadius: `${theme.shape.borderRadius}px`,
|
||||||
|
@ -2,22 +2,26 @@ import React, { useState } from 'react';
|
|||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Menu from '@mui/material/Menu';
|
import Menu from '@mui/material/Menu';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
import { IFilterVisibility, IFilterItem } from '../FeatureToggleFilters';
|
import { styled } from '@mui/material';
|
||||||
import { Box, styled } from '@mui/material';
|
|
||||||
import { Add } from '@mui/icons-material';
|
import { Add } from '@mui/icons-material';
|
||||||
|
|
||||||
const StyledButton = styled(Button)(({ theme }) => ({
|
const StyledButton = styled(Button)(({ theme }) => ({
|
||||||
margin: theme.spacing(-1, 0, -1, 0),
|
margin: theme.spacing(-1, 0, -1, 0),
|
||||||
padding: theme.spacing(1.25),
|
padding: theme.spacing(1.25),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface IAddFilterButtonProps {
|
interface IAddFilterButtonProps {
|
||||||
visibleFilters: IFilterVisibility;
|
visibleOptions: string[];
|
||||||
setVisibleFilters: (filters: IFilterVisibility) => void;
|
setVisibleOptions: (filters: string[]) => void;
|
||||||
|
hiddenOptions: string[];
|
||||||
|
setHiddenOptions: (filters: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddFilterButton = ({
|
const AddFilterButton = ({
|
||||||
visibleFilters,
|
visibleOptions,
|
||||||
setVisibleFilters,
|
setVisibleOptions,
|
||||||
|
hiddenOptions,
|
||||||
|
setHiddenOptions,
|
||||||
}: IAddFilterButtonProps) => {
|
}: IAddFilterButtonProps) => {
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
@ -28,12 +32,12 @@ const AddFilterButton = ({
|
|||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClick = (label: string) => {
|
const onSelect = (label: string) => {
|
||||||
const filterVisibility = {
|
const newVisibleOptions = visibleOptions.filter((f) => f !== label);
|
||||||
...visibleFilters,
|
const newHiddenOptions = [...hiddenOptions, label];
|
||||||
[label]: true,
|
|
||||||
};
|
setHiddenOptions(newHiddenOptions);
|
||||||
setVisibleFilters(filterVisibility);
|
setVisibleOptions(newVisibleOptions);
|
||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -49,13 +53,11 @@ const AddFilterButton = ({
|
|||||||
open={Boolean(anchorEl)}
|
open={Boolean(anchorEl)}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
>
|
>
|
||||||
{Object.entries(visibleFilters).map(([label, enabled]) =>
|
{visibleOptions.map((label) => (
|
||||||
!enabled ? (
|
<MenuItem key={label} onClick={() => onSelect(label)}>
|
||||||
<MenuItem key={label} onClick={() => onClick(label)}>
|
{label}
|
||||||
{label}
|
</MenuItem>
|
||||||
</MenuItem>
|
))}
|
||||||
) : null,
|
|
||||||
)}
|
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -2,6 +2,7 @@ import { screen } from '@testing-library/react';
|
|||||||
import { render } from 'utils/testRenderer';
|
import { render } from 'utils/testRenderer';
|
||||||
import { testServerRoute, testServerSetup } from 'utils/testServer';
|
import { testServerRoute, testServerSetup } from 'utils/testServer';
|
||||||
import { FeatureToggleFilters } from './FeatureToggleFilters';
|
import { FeatureToggleFilters } from './FeatureToggleFilters';
|
||||||
|
import { FILTER_ITEM } from 'utils/testIds';
|
||||||
|
|
||||||
const server = testServerSetup();
|
const server = testServerSetup();
|
||||||
|
|
||||||
@ -38,3 +39,21 @@ test('should not render projects filters when less than two project', async () =
|
|||||||
|
|
||||||
expect(screen.queryByText('Projects')).not.toBeInTheDocument();
|
expect(screen.queryByText('Projects')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should keep filters order when adding a new filter', async () => {
|
||||||
|
render(<FeatureToggleFilters onChange={() => {}} state={{}} />);
|
||||||
|
|
||||||
|
const valuesElement = await screen.findByText('Tags');
|
||||||
|
expect(valuesElement).toBeInTheDocument();
|
||||||
|
valuesElement.click();
|
||||||
|
|
||||||
|
const stateElement = await screen.findByText('State');
|
||||||
|
expect(stateElement).toBeInTheDocument();
|
||||||
|
|
||||||
|
stateElement.click();
|
||||||
|
|
||||||
|
const filterItems = screen.getAllByTestId(FILTER_ITEM);
|
||||||
|
const filterTexts = filterItems.map((item) => item.textContent);
|
||||||
|
|
||||||
|
expect(filterTexts).toEqual(['Tags', 'State']);
|
||||||
|
});
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
FilterItemParams,
|
FilterItemParams,
|
||||||
} from 'component/common/FilterItem/FilterItem';
|
} from 'component/common/FilterItem/FilterItem';
|
||||||
import useAllTags from 'hooks/api/getters/useAllTags/useAllTags';
|
import useAllTags from 'hooks/api/getters/useAllTags/useAllTags';
|
||||||
|
import { FILTER_ITEM } from 'utils/testIds';
|
||||||
|
|
||||||
const StyledBox = styled(Box)(({ theme }) => ({
|
const StyledBox = styled(Box)(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -42,10 +43,6 @@ export interface IFilterItem {
|
|||||||
pluralOperators: [string, ...string[]];
|
pluralOperators: [string, ...string[]];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IFilterVisibility = {
|
|
||||||
[key: string]: boolean | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FeatureToggleFilters: VFC<IFeatureToggleFiltersProps> = ({
|
export const FeatureToggleFilters: VFC<IFeatureToggleFiltersProps> = ({
|
||||||
state,
|
state,
|
||||||
onChange,
|
onChange,
|
||||||
@ -66,14 +63,30 @@ export const FeatureToggleFilters: VFC<IFeatureToggleFiltersProps> = ({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const [availableFilters, setAvailableFilters] = useState<IFilterItem[]>([]);
|
const [availableFilters, setAvailableFilters] = useState<IFilterItem[]>([]);
|
||||||
const [visibleFilters, setVisibleFilters] = useState<IFilterVisibility>({});
|
const [unselectedFilters, setUnselectedFilters] = useState<string[]>([]);
|
||||||
|
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
|
||||||
|
|
||||||
const hideFilter = (label: string) => {
|
const deselectFilter = (label: string) => {
|
||||||
const filterVisibility = {
|
const newSelectedFilters = selectedFilters.filter((f) => f !== label);
|
||||||
...visibleFilters,
|
const newUnselectedFilters = [...unselectedFilters, label].sort();
|
||||||
[label]: false,
|
|
||||||
};
|
setSelectedFilters(newSelectedFilters);
|
||||||
setVisibleFilters(filterVisibility);
|
setUnselectedFilters(newUnselectedFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeArraysKeepingOrder = (
|
||||||
|
firstArray: string[],
|
||||||
|
secondArray: string[],
|
||||||
|
): string[] => {
|
||||||
|
const elementsSet = new Set(firstArray);
|
||||||
|
|
||||||
|
secondArray.forEach((element) => {
|
||||||
|
if (!elementsSet.has(element)) {
|
||||||
|
firstArray.push(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return firstArray;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -147,59 +160,102 @@ export const FeatureToggleFilters: VFC<IFeatureToggleFiltersProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasMultipleProjects = projects.length > 1;
|
const hasMultipleProjects = projects.length > 1;
|
||||||
|
|
||||||
const filterVisibility: IFilterVisibility = {
|
const fieldsMapping = [
|
||||||
State: Boolean(state.state),
|
{
|
||||||
...(hasMultipleProjects && {
|
stateField: 'state',
|
||||||
Project: Boolean(state.project),
|
label: 'State',
|
||||||
}),
|
},
|
||||||
Tags: Boolean(state.tag),
|
...(hasMultipleProjects
|
||||||
Segment: Boolean(state.segment),
|
? [
|
||||||
'Created date': Boolean(state.createdAt),
|
{
|
||||||
};
|
stateField: 'project',
|
||||||
setVisibleFilters(filterVisibility);
|
label: 'Project',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
stateField: 'tag',
|
||||||
|
label: 'Tags',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stateField: 'segment',
|
||||||
|
label: 'Segment',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stateField: 'createdAt',
|
||||||
|
label: 'Created date',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const newSelectedFilters = fieldsMapping
|
||||||
|
.filter((field) =>
|
||||||
|
Boolean(
|
||||||
|
state[field.stateField as keyof FeatureTogglesListFilters],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((field) => field.label);
|
||||||
|
const newUnselectedFilters = fieldsMapping
|
||||||
|
.filter(
|
||||||
|
(field) =>
|
||||||
|
!state[field.stateField as keyof FeatureTogglesListFilters],
|
||||||
|
)
|
||||||
|
.map((field) => field.label)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
setSelectedFilters(
|
||||||
|
mergeArraysKeepingOrder(selectedFilters, newSelectedFilters),
|
||||||
|
);
|
||||||
|
setUnselectedFilters(newUnselectedFilters);
|
||||||
}, [JSON.stringify(state), JSON.stringify(projects)]);
|
}, [JSON.stringify(state), JSON.stringify(projects)]);
|
||||||
|
|
||||||
const hasAvailableFilters = Object.values(visibleFilters).some(
|
const hasAvailableFilters = unselectedFilters.length > 0;
|
||||||
(value) => !value,
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<StyledBox>
|
<StyledBox>
|
||||||
{availableFilters.map(
|
{selectedFilters.map((selectedFilter) => {
|
||||||
(filter) =>
|
if (selectedFilter === 'Created date') {
|
||||||
visibleFilters[filter.label] && (
|
return (
|
||||||
<FilterItem
|
<FilterDateItem
|
||||||
key={filter.label}
|
label={'Created date'}
|
||||||
label={filter.label}
|
state={state.createdAt}
|
||||||
state={state[filter.filterKey]}
|
onChange={(value) => onChange({ createdAt: value })}
|
||||||
options={filter.options}
|
operators={['IS_ON_OR_AFTER', 'IS_BEFORE']}
|
||||||
onChange={(value) =>
|
onChipClose={() => deselectFilter('Created date')}
|
||||||
onChange({ [filter.filterKey]: value })
|
|
||||||
}
|
|
||||||
singularOperators={filter.singularOperators}
|
|
||||||
pluralOperators={filter.pluralOperators}
|
|
||||||
onChipClose={() => hideFilter(filter.label)}
|
|
||||||
/>
|
/>
|
||||||
),
|
);
|
||||||
)}
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={Boolean(visibleFilters['Created date'])}
|
|
||||||
show={
|
|
||||||
<FilterDateItem
|
|
||||||
label={'Created date'}
|
|
||||||
state={state.createdAt}
|
|
||||||
onChange={(value) => onChange({ createdAt: value })}
|
|
||||||
operators={['IS_ON_OR_AFTER', 'IS_BEFORE']}
|
|
||||||
onChipClose={() => hideFilter('Created date')}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
/>
|
|
||||||
|
const filter = availableFilters.find(
|
||||||
|
(filter) => filter.label === selectedFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!filter) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FilterItem
|
||||||
|
key={filter.label}
|
||||||
|
label={filter.label}
|
||||||
|
state={state[filter.filterKey]}
|
||||||
|
options={filter.options}
|
||||||
|
onChange={(value) =>
|
||||||
|
onChange({ [filter.filterKey]: value })
|
||||||
|
}
|
||||||
|
singularOperators={filter.singularOperators}
|
||||||
|
pluralOperators={filter.pluralOperators}
|
||||||
|
onChipClose={() => deselectFilter(filter.label)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={hasAvailableFilters}
|
condition={hasAvailableFilters}
|
||||||
show={
|
show={
|
||||||
<AddFilterButton
|
<AddFilterButton
|
||||||
visibleFilters={visibleFilters}
|
visibleOptions={unselectedFilters}
|
||||||
setVisibleFilters={setVisibleFilters}
|
setVisibleOptions={setUnselectedFilters}
|
||||||
|
hiddenOptions={selectedFilters}
|
||||||
|
setHiddenOptions={setSelectedFilters}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -98,3 +98,5 @@ export const BATCH_ACTIONS_BAR = 'BATCH_ACTIONS_BAR';
|
|||||||
export const BATCH_SELECTED_COUNT = 'BATCH_SELECTED_COUNT';
|
export const BATCH_SELECTED_COUNT = 'BATCH_SELECTED_COUNT';
|
||||||
export const BATCH_SELECT = 'BATCH_SELECT';
|
export const BATCH_SELECT = 'BATCH_SELECT';
|
||||||
export const MORE_BATCH_ACTIONS = 'MORE_BATCH_ACTIONS';
|
export const MORE_BATCH_ACTIONS = 'MORE_BATCH_ACTIONS';
|
||||||
|
|
||||||
|
export const FILTER_ITEM = 'FILTER_ITEM';
|
||||||
|
Loading…
Reference in New Issue
Block a user