mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +02:00
Basic filter label (#5387)
This commit is contained in:
parent
b0c05111c6
commit
ffe37ac709
@ -0,0 +1,37 @@
|
||||
import { Checkbox, ListItem, Popover, TextField, styled } from '@mui/material';
|
||||
|
||||
export const StyledDropdown = styled('div')(({ theme }) => ({
|
||||
padding: theme.spacing(1.5),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
}));
|
||||
|
||||
export const StyledListItem = styled(ListItem)(({ theme }) => ({
|
||||
paddingLeft: theme.spacing(1),
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
}));
|
||||
|
||||
export const StyledCheckbox = styled(Checkbox)(({ theme }) => ({
|
||||
padding: theme.spacing(1, 1, 1, 1.5),
|
||||
}));
|
||||
|
||||
export const StyledPopover = styled(Popover)(({ theme }) => ({
|
||||
'& .MuiPaper-root': {
|
||||
borderRadius: `${theme.shape.borderRadiusMedium}px`,
|
||||
},
|
||||
}));
|
||||
|
||||
export const StyledTextField = styled(TextField)(({ theme }) => ({
|
||||
'& .MuiInputBase-root': {
|
||||
padding: theme.spacing(0, 1.5),
|
||||
borderRadius: `${theme.shape.borderRadiusMedium}px`,
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
padding: theme.spacing(0.75, 0),
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
},
|
||||
}));
|
159
frontend/src/component/common/FilterItem/FilterItem.tsx
Normal file
159
frontend/src/component/common/FilterItem/FilterItem.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import { Search } from '@mui/icons-material';
|
||||
import { List, ListItemText, Box, InputAdornment } from '@mui/material';
|
||||
import { FC, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
StyledCheckbox,
|
||||
StyledDropdown,
|
||||
StyledListItem,
|
||||
StyledPopover,
|
||||
StyledTextField,
|
||||
} from './FilterItem.styles';
|
||||
import { FilterItemChip } from './FilterItemChip/FilterItemChip';
|
||||
|
||||
interface IFilterItemProps {
|
||||
label: string;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
const singularOperators = ['IS', 'IS_NOT'];
|
||||
const pluralOperators = ['IS_IN', 'IS_NOT_IN'];
|
||||
|
||||
export const FilterItem: FC<IFilterItemProps> = ({ label, options }) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [selectedOptions, setSelectedOptions] = useState<typeof options>([]);
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const currentOperators =
|
||||
selectedOptions?.length > 1 ? pluralOperators : singularOperators;
|
||||
const [operator, setOperator] = useState(currentOperators[0]);
|
||||
|
||||
const onClick = () => {
|
||||
setAnchorEl(ref.current);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
setSelectedOptions([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleToggle = (value: string) => () => {
|
||||
if (
|
||||
selectedOptions?.some(
|
||||
(selectedOption) => selectedOption.value === value,
|
||||
)
|
||||
) {
|
||||
setSelectedOptions(
|
||||
selectedOptions?.filter(
|
||||
(selectedOption) => selectedOption.value !== value,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setSelectedOptions([
|
||||
...(selectedOptions ?? []),
|
||||
options.find((option) => option.value === value) ?? {
|
||||
label: '',
|
||||
value: '',
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentOperators.includes(operator)) {
|
||||
setOperator(currentOperators[0]);
|
||||
}
|
||||
}, [currentOperators, operator]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box ref={ref}>
|
||||
<FilterItemChip
|
||||
label={label}
|
||||
selectedOptions={selectedOptions?.map(
|
||||
(option) => option?.label,
|
||||
)}
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
operator={operator}
|
||||
operatorOptions={currentOperators}
|
||||
onChangeOperator={setOperator}
|
||||
/>
|
||||
</Box>
|
||||
<StyledPopover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={onClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
<StyledDropdown>
|
||||
<StyledTextField
|
||||
variant='outlined'
|
||||
size='small'
|
||||
value={searchText}
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
placeholder='Search'
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position='start'>
|
||||
<Search fontSize='small' />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<List disablePadding>
|
||||
{options
|
||||
?.filter((option) =>
|
||||
option.label
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()),
|
||||
)
|
||||
.map((option) => {
|
||||
const labelId = `checkbox-list-label-${option.value}`;
|
||||
|
||||
return (
|
||||
<StyledListItem
|
||||
key={option.value}
|
||||
dense
|
||||
disablePadding
|
||||
onClick={handleToggle(option.value)}
|
||||
>
|
||||
<StyledCheckbox
|
||||
edge='start'
|
||||
checked={
|
||||
selectedOptions?.some(
|
||||
(selectedOption) =>
|
||||
selectedOption.value ===
|
||||
option.value,
|
||||
) ?? false
|
||||
}
|
||||
tabIndex={-1}
|
||||
inputProps={{
|
||||
'aria-labelledby': labelId,
|
||||
}}
|
||||
size='small'
|
||||
disableRipple
|
||||
/>
|
||||
<ListItemText
|
||||
id={labelId}
|
||||
primary={option.label}
|
||||
/>
|
||||
</StyledListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</StyledDropdown>
|
||||
</StyledPopover>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,132 @@
|
||||
import { ComponentProps, FC } from 'react';
|
||||
import {} from '../FilterItem.styles';
|
||||
import { ArrowDropDown, Close, TopicOutlined } from '@mui/icons-material';
|
||||
import { ConditionallyRender } from '../../ConditionallyRender/ConditionallyRender';
|
||||
import { Chip, IconButton, styled } from '@mui/material';
|
||||
import { FilterItemOperator } from './FilterItemOperator/FilterItemOperator';
|
||||
|
||||
const StyledChip = styled(
|
||||
({
|
||||
isActive,
|
||||
...props
|
||||
}: { isActive: boolean } & ComponentProps<typeof Chip>) => (
|
||||
<Chip {...props} />
|
||||
),
|
||||
)(({ theme, isActive = false }) => ({
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
padding: 0,
|
||||
margin: theme.spacing(0, 0, 1, 0),
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
...(isActive
|
||||
? {
|
||||
backgroundColor: theme.palette.secondary.light,
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
|
||||
const StyledLabel = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
}));
|
||||
|
||||
const StyledCategoryIconWrapper = styled('div')(({ theme }) => ({
|
||||
marginRight: theme.spacing(1),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: theme.typography.h2.fontSize,
|
||||
}));
|
||||
|
||||
const StyledOptions = styled('span')(({ theme }) => ({
|
||||
color: theme.palette.text.primary,
|
||||
fontWeight: theme.typography.fontWeightRegular,
|
||||
}));
|
||||
|
||||
const StyledIconButton = styled(IconButton)(({ theme }) => ({
|
||||
marginLeft: theme.spacing(0.5),
|
||||
marginRight: theme.spacing(-1.25),
|
||||
}));
|
||||
|
||||
const Arrow = () => (
|
||||
<ArrowDropDown
|
||||
fontSize='small'
|
||||
color='action'
|
||||
sx={(theme) => ({
|
||||
marginRight: theme.spacing(-1),
|
||||
marginLeft: theme.spacing(0.5),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
interface IFilterItemChipProps {
|
||||
label: string;
|
||||
selectedOptions?: string[];
|
||||
operatorOptions: string[];
|
||||
operator: string;
|
||||
onChangeOperator: (value: string) => void;
|
||||
onClick?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export const FilterItemChip: FC<IFilterItemChipProps> = ({
|
||||
label,
|
||||
selectedOptions = [],
|
||||
operatorOptions,
|
||||
operator,
|
||||
onChangeOperator,
|
||||
onClick,
|
||||
onDelete,
|
||||
}) => {
|
||||
const hasSelectedOptions = selectedOptions.length > 0;
|
||||
|
||||
return (
|
||||
<StyledChip
|
||||
isActive={hasSelectedOptions}
|
||||
label={
|
||||
<StyledLabel>
|
||||
<StyledCategoryIconWrapper>
|
||||
<TopicOutlined fontSize='inherit' />
|
||||
</StyledCategoryIconWrapper>
|
||||
{label}
|
||||
<ConditionallyRender
|
||||
condition={!hasSelectedOptions}
|
||||
show={() => <Arrow />}
|
||||
elseShow={() => (
|
||||
<>
|
||||
<FilterItemOperator
|
||||
options={operatorOptions}
|
||||
value={operator}
|
||||
onChange={onChangeOperator}
|
||||
/>
|
||||
<StyledOptions>
|
||||
{selectedOptions.join(', ')}
|
||||
</StyledOptions>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(onDelete && hasSelectedOptions)}
|
||||
show={() => (
|
||||
<StyledIconButton
|
||||
aria-label='delete'
|
||||
color='primary'
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onDelete?.();
|
||||
}}
|
||||
size='small'
|
||||
>
|
||||
<Close fontSize='inherit' color='action' />
|
||||
</StyledIconButton>
|
||||
)}
|
||||
/>
|
||||
</StyledLabel>
|
||||
}
|
||||
color='primary'
|
||||
variant='outlined'
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,80 @@
|
||||
import { styled, Menu, MenuItem } from '@mui/material';
|
||||
import { FC, useState, MouseEvent } from 'react';
|
||||
|
||||
interface IFilterItemOperatorProps {
|
||||
options: string[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const StyledOperator = styled('button')(({ theme }) => ({
|
||||
borderRadius: 0,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: theme.palette.text.disabled,
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
padding: theme.spacing(0, 0.75),
|
||||
margin: theme.spacing(0, 0.75),
|
||||
height: theme.spacing(3.75),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledMenu = styled(Menu)(({ theme }) => ({
|
||||
transform: `translateY(${theme.spacing(0.5)})`,
|
||||
}));
|
||||
|
||||
const formatOption = (option: string) =>
|
||||
option.replaceAll('_', ' ').toLocaleLowerCase();
|
||||
|
||||
export const FilterItemOperator: FC<IFilterItemOperatorProps> = ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuItemClick =
|
||||
(option: string) => (event: MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
onChange(option);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledOperator onClick={handleClick}>
|
||||
{formatOption(value)}
|
||||
</StyledOperator>
|
||||
<StyledMenu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleClose}
|
||||
anchorPosition={{ left: 0, top: 1 }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<MenuItem
|
||||
key={option}
|
||||
onClick={handleMenuItemClick(option)}
|
||||
>
|
||||
{formatOption(option)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</StyledMenu>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState, VFC } from 'react';
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Link,
|
||||
Tooltip,
|
||||
@ -39,6 +40,8 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { focusable } from 'themes/themeStyles';
|
||||
import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
|
||||
import useToast from 'hooks/useToast';
|
||||
import { FilterItem } from 'component/common/FilterItem/FilterItem';
|
||||
import { useUiFlag } from 'hooks/useUiFlag';
|
||||
|
||||
export const featuresPlaceholder: FeatureSchema[] = Array(15).fill({
|
||||
name: 'Name of the feature',
|
||||
@ -73,6 +76,7 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
const { setToastApiError } = useToast();
|
||||
const { uiConfig } = useUiConfig();
|
||||
|
||||
const featureSearchFrontend = useUiFlag('featureSearchFrontend');
|
||||
const [initialState] = useState(() => ({
|
||||
sortBy: [
|
||||
{
|
||||
@ -367,6 +371,27 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
</PageHeader>
|
||||
}
|
||||
>
|
||||
{featureSearchFrontend && (
|
||||
<Box sx={(theme) => ({ marginBottom: theme.spacing(2) })}>
|
||||
<FilterItem
|
||||
label='Project'
|
||||
options={[
|
||||
{
|
||||
label: 'Project 1',
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
label: 'Test',
|
||||
value: '2',
|
||||
},
|
||||
{
|
||||
label: 'Default',
|
||||
value: '3',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
||||
<VirtualizedTable
|
||||
rows={rows}
|
||||
|
Loading…
Reference in New Issue
Block a user