From ffe37ac70997734d45988f63833e3cac44a0364a Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Fri, 24 Nov 2023 10:07:42 +0100 Subject: [PATCH] Basic filter label (#5387) --- .../common/FilterItem/FilterItem.styles.tsx | 37 ++++ .../common/FilterItem/FilterItem.tsx | 159 ++++++++++++++++++ .../FilterItemChip/FilterItemChip.tsx | 132 +++++++++++++++ .../FilterItemOperator/FilterItemOperator.tsx | 80 +++++++++ .../FeatureToggleListTable.tsx | 25 +++ 5 files changed, 433 insertions(+) create mode 100644 frontend/src/component/common/FilterItem/FilterItem.styles.tsx create mode 100644 frontend/src/component/common/FilterItem/FilterItem.tsx create mode 100644 frontend/src/component/common/FilterItem/FilterItemChip/FilterItemChip.tsx create mode 100644 frontend/src/component/common/FilterItem/FilterItemChip/FilterItemOperator/FilterItemOperator.tsx diff --git a/frontend/src/component/common/FilterItem/FilterItem.styles.tsx b/frontend/src/component/common/FilterItem/FilterItem.styles.tsx new file mode 100644 index 0000000000..57407219a1 --- /dev/null +++ b/frontend/src/component/common/FilterItem/FilterItem.styles.tsx @@ -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, + }, +})); diff --git a/frontend/src/component/common/FilterItem/FilterItem.tsx b/frontend/src/component/common/FilterItem/FilterItem.tsx new file mode 100644 index 0000000000..100fa07336 --- /dev/null +++ b/frontend/src/component/common/FilterItem/FilterItem.tsx @@ -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 = ({ label, options }) => { + const ref = useRef(null); + const [selectedOptions, setSelectedOptions] = useState([]); + const [anchorEl, setAnchorEl] = useState(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 ( + <> + + option?.label, + )} + onDelete={onDelete} + onClick={onClick} + operator={operator} + operatorOptions={currentOperators} + onChangeOperator={setOperator} + /> + + + + setSearchText(event.target.value)} + placeholder='Search' + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + {options + ?.filter((option) => + option.label + .toLowerCase() + .includes(searchText.toLowerCase()), + ) + .map((option) => { + const labelId = `checkbox-list-label-${option.value}`; + + return ( + + + selectedOption.value === + option.value, + ) ?? false + } + tabIndex={-1} + inputProps={{ + 'aria-labelledby': labelId, + }} + size='small' + disableRipple + /> + + + ); + })} + + + + + ); +}; diff --git a/frontend/src/component/common/FilterItem/FilterItemChip/FilterItemChip.tsx b/frontend/src/component/common/FilterItem/FilterItemChip/FilterItemChip.tsx new file mode 100644 index 0000000000..fad4c70ce5 --- /dev/null +++ b/frontend/src/component/common/FilterItem/FilterItemChip/FilterItemChip.tsx @@ -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) => ( + + ), +)(({ 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 = () => ( + ({ + 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 = ({ + label, + selectedOptions = [], + operatorOptions, + operator, + onChangeOperator, + onClick, + onDelete, +}) => { + const hasSelectedOptions = selectedOptions.length > 0; + + return ( + + + + + {label} + } + elseShow={() => ( + <> + + + {selectedOptions.join(', ')} + + + )} + /> + ( + { + event.preventDefault(); + event.stopPropagation(); + onDelete?.(); + }} + size='small' + > + + + )} + /> + + } + color='primary' + variant='outlined' + onClick={onClick} + /> + ); +}; diff --git a/frontend/src/component/common/FilterItem/FilterItemChip/FilterItemOperator/FilterItemOperator.tsx b/frontend/src/component/common/FilterItem/FilterItemChip/FilterItemOperator/FilterItemOperator.tsx new file mode 100644 index 0000000000..ba68928486 --- /dev/null +++ b/frontend/src/component/common/FilterItem/FilterItemChip/FilterItemOperator/FilterItemOperator.tsx @@ -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 = ({ + options, + value, + onChange, +}) => { + const [anchorEl, setAnchorEl] = useState(null); + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleClick = (event: MouseEvent) => { + event.stopPropagation(); + setAnchorEl(event.currentTarget); + }; + + const handleMenuItemClick = + (option: string) => (event: MouseEvent) => { + event.stopPropagation(); + onChange(option); + handleClose(); + }; + + return ( + <> + + {formatOption(value)} + + event.stopPropagation()} + > + {options.map((option) => ( + + {formatOption(option)} + + ))} + + + ); +}; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx index 92c257f9da..401f4eafda 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx @@ -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 = () => { } > + {featureSearchFrontend && ( + ({ marginBottom: theme.spacing(2) })}> + + + )}