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 { useCallback, useEffect, useMemo, useState, VFC } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Box,
|
||||||
IconButton,
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@ -39,6 +40,8 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
|||||||
import { focusable } from 'themes/themeStyles';
|
import { focusable } from 'themes/themeStyles';
|
||||||
import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
|
import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
|
||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
|
import { FilterItem } from 'component/common/FilterItem/FilterItem';
|
||||||
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
|
|
||||||
export const featuresPlaceholder: FeatureSchema[] = Array(15).fill({
|
export const featuresPlaceholder: FeatureSchema[] = Array(15).fill({
|
||||||
name: 'Name of the feature',
|
name: 'Name of the feature',
|
||||||
@ -73,6 +76,7 @@ export const FeatureToggleListTable: VFC = () => {
|
|||||||
const { setToastApiError } = useToast();
|
const { setToastApiError } = useToast();
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
|
const featureSearchFrontend = useUiFlag('featureSearchFrontend');
|
||||||
const [initialState] = useState(() => ({
|
const [initialState] = useState(() => ({
|
||||||
sortBy: [
|
sortBy: [
|
||||||
{
|
{
|
||||||
@ -367,6 +371,27 @@ export const FeatureToggleListTable: VFC = () => {
|
|||||||
</PageHeader>
|
</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)}>
|
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
||||||
<VirtualizedTable
|
<VirtualizedTable
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
Loading…
Reference in New Issue
Block a user