mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-04 00:18:01 +01:00
feat: add filtering capabilities to search (#1052)
* feat: add filtering capabilities to search Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com> * fix: state custom filter * fix: undefined search crash * feat: add suggestions component * make search visible all the time * fix: update snaps * refactor, add tests, filterParsing, pass down searchContext to search components Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com> * refactor: TableSearchFieldSuggestions and improvements * some cleanup and fix edge cases * adapt new search in project feature toggles * small ui/ux improvements * refactor: suggestions into smaller components * fix: update snaps * add responsiveness to the search * fix: update snaps Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>
This commit is contained in:
parent
32ada96220
commit
4a5ed3c3e7
@ -28,14 +28,4 @@ export const useStyles = makeStyles()(theme => ({
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
},
|
||||
verticalSeparator: {
|
||||
height: '100%',
|
||||
borderColor: theme.palette.dividerAlternative,
|
||||
width: '1px',
|
||||
display: 'inline-block',
|
||||
marginLeft: theme.spacing(2),
|
||||
marginRight: theme.spacing(4),
|
||||
padding: '10px 0',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
}));
|
||||
|
@ -1,12 +1,30 @@
|
||||
import { ReactNode, FC, VFC } from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { Divider, Typography, TypographyProps } from '@mui/material';
|
||||
import {
|
||||
Divider,
|
||||
styled,
|
||||
SxProps,
|
||||
Theme,
|
||||
Typography,
|
||||
TypographyProps,
|
||||
} from '@mui/material';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
|
||||
import { useStyles } from './PageHeader.styles';
|
||||
import { usePageTitle } from 'hooks/usePageTitle';
|
||||
|
||||
const StyledDivider = styled(Divider)(({ theme }) => ({
|
||||
height: '100%',
|
||||
borderColor: theme.palette.dividerAlternative,
|
||||
width: '1px',
|
||||
display: 'inline-block',
|
||||
marginLeft: theme.spacing(2),
|
||||
marginRight: theme.spacing(2),
|
||||
padding: '10px 0',
|
||||
verticalAlign: 'middle',
|
||||
}));
|
||||
|
||||
interface IPageHeaderProps {
|
||||
title: string;
|
||||
titleElement?: ReactNode;
|
||||
@ -17,7 +35,9 @@ interface IPageHeaderProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PageHeaderComponent: FC<IPageHeaderProps> & { Divider: VFC } = ({
|
||||
const PageHeaderComponent: FC<IPageHeaderProps> & {
|
||||
Divider: typeof PageHeaderDivider;
|
||||
} = ({
|
||||
title,
|
||||
titleElement,
|
||||
actions,
|
||||
@ -57,16 +77,8 @@ const PageHeaderComponent: FC<IPageHeaderProps> & { Divider: VFC } = ({
|
||||
);
|
||||
};
|
||||
|
||||
const PageHeaderDivider: VFC = () => {
|
||||
const { classes: styles } = useStyles();
|
||||
|
||||
return (
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
variant="middle"
|
||||
className={styles.verticalSeparator}
|
||||
/>
|
||||
);
|
||||
const PageHeaderDivider: VFC<{ sx?: SxProps<Theme> }> = ({ sx }) => {
|
||||
return <StyledDivider orientation="vertical" variant="middle" sx={sx} />;
|
||||
};
|
||||
|
||||
PageHeaderComponent.Divider = PageHeaderDivider;
|
||||
|
@ -1,59 +0,0 @@
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
export const useStyles = makeStyles()(theme => ({
|
||||
searchField: {
|
||||
width: '45px',
|
||||
'& .search-icon': {
|
||||
marginRight: 0,
|
||||
},
|
||||
'& .input-container, .clear-container': {
|
||||
width: 0,
|
||||
},
|
||||
'& input::placeholder': {
|
||||
color: 'transparent',
|
||||
transition: 'color 0.6s',
|
||||
},
|
||||
'& input:focus-within::placeholder': {
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
},
|
||||
searchFieldEnter: {
|
||||
width: '250px',
|
||||
transition: 'width 0.6s',
|
||||
'& .search-icon': {
|
||||
marginRight: '8px',
|
||||
},
|
||||
'& .input-container': {
|
||||
width: '100%',
|
||||
transition: 'width 0.6s',
|
||||
},
|
||||
'& .clear-container': {
|
||||
width: '30px',
|
||||
transition: 'width 0.6s',
|
||||
},
|
||||
'& .search-container': {
|
||||
borderColor: theme.palette.grey[300],
|
||||
},
|
||||
},
|
||||
searchFieldLeave: {
|
||||
width: '45px',
|
||||
transition: 'width 0.6s',
|
||||
'& .search-icon': {
|
||||
marginRight: 0,
|
||||
transition: 'margin-right 0.6s',
|
||||
},
|
||||
'& .input-container, .clear-container': {
|
||||
width: 0,
|
||||
transition: 'width 0.6s',
|
||||
},
|
||||
'& .search-container': {
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
},
|
||||
searchButton: {
|
||||
marginTop: '-4px',
|
||||
marginBottom: '-4px',
|
||||
marginRight: '-4px',
|
||||
marginLeft: '-4px',
|
||||
},
|
||||
}));
|
@ -1,75 +1,38 @@
|
||||
import { IGetSearchContextOutput } from 'hooks/useSearch';
|
||||
import { FC, useState } from 'react';
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
import { Search } from '@mui/icons-material';
|
||||
import { useAsyncDebounce } from 'react-table';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import AnimateOnMount from 'component/common/AnimateOnMount/AnimateOnMount';
|
||||
import { TableSearchField } from './TableSearchField/TableSearchField';
|
||||
import { useStyles } from './TableSearch.styles';
|
||||
|
||||
interface ITableSearchProps {
|
||||
initialValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
hasFilters?: boolean;
|
||||
getSearchContext?: () => IGetSearchContextOutput;
|
||||
}
|
||||
|
||||
export const TableSearch: FC<ITableSearchProps> = ({
|
||||
initialValue,
|
||||
onChange = () => {},
|
||||
placeholder = 'Search',
|
||||
hasFilters,
|
||||
getSearchContext,
|
||||
}) => {
|
||||
const [searchInputState, setSearchInputState] = useState(initialValue);
|
||||
const [isSearchExpanded, setIsSearchExpanded] = useState(
|
||||
Boolean(initialValue)
|
||||
);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const debouncedOnSearch = useAsyncDebounce(onChange, 200);
|
||||
|
||||
const { classes: styles } = useStyles();
|
||||
|
||||
const onBlur = (clear = false) => {
|
||||
if (!searchInputState || clear) {
|
||||
setIsSearchExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSearchChange = (value: string) => {
|
||||
debouncedOnSearch(value);
|
||||
setSearchInputState(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimateOnMount
|
||||
mounted={isSearchExpanded}
|
||||
start={styles.searchField}
|
||||
enter={styles.searchFieldEnter}
|
||||
leave={styles.searchFieldLeave}
|
||||
onStart={() => setIsAnimating(true)}
|
||||
onEnd={() => setIsAnimating(false)}
|
||||
>
|
||||
<TableSearchField
|
||||
value={searchInputState!}
|
||||
onChange={onSearchChange}
|
||||
placeholder={`${placeholder}…`}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</AnimateOnMount>
|
||||
<ConditionallyRender
|
||||
condition={!isSearchExpanded && !isAnimating}
|
||||
show={
|
||||
<Tooltip title={placeholder} arrow>
|
||||
<IconButton
|
||||
aria-label={placeholder}
|
||||
onClick={() => setIsSearchExpanded(true)}
|
||||
size="large"
|
||||
className={styles.searchButton}
|
||||
>
|
||||
<Search />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
<TableSearchField
|
||||
value={searchInputState!}
|
||||
onChange={onSearchChange}
|
||||
placeholder={`${placeholder}…`}
|
||||
hasFilters={hasFilters}
|
||||
getSearchContext={getSearchContext}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -3,9 +3,14 @@ import { makeStyles } from 'tss-react/mui';
|
||||
export const useStyles = makeStyles()(theme => ({
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: '1rem',
|
||||
position: 'relative',
|
||||
maxWidth: '400px',
|
||||
[theme.breakpoints.down('md')]: {
|
||||
marginTop: theme.spacing(1),
|
||||
maxWidth: '100%',
|
||||
},
|
||||
},
|
||||
search: {
|
||||
display: 'flex',
|
||||
@ -14,10 +19,8 @@ export const useStyles = makeStyles()(theme => ({
|
||||
border: `1px solid ${theme.palette.grey[300]}`,
|
||||
borderRadius: theme.shape.borderRadiusExtraLarge,
|
||||
padding: '3px 5px 3px 12px',
|
||||
maxWidth: '450px',
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
width: '100%',
|
||||
},
|
||||
width: '100%',
|
||||
zIndex: 3,
|
||||
'&.search-container:focus-within': {
|
||||
borderColor: theme.palette.primary.light,
|
||||
boxShadow: theme.boxShadows.main,
|
||||
|
@ -3,13 +3,17 @@ import { Search, Close } from '@mui/icons-material';
|
||||
import classnames from 'classnames';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { useStyles } from './TableSearchField.styles';
|
||||
import { TableSearchFieldSuggestions } from './TableSearchFieldSuggestions/TableSearchFieldSuggestions';
|
||||
import { useState } from 'react';
|
||||
import { IGetSearchContextOutput } from 'hooks/useSearch';
|
||||
|
||||
interface ITableSearchFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
placeholder: string;
|
||||
onBlur?: (clear?: boolean) => void;
|
||||
hasFilters?: boolean;
|
||||
getSearchContext?: () => IGetSearchContextOutput;
|
||||
}
|
||||
|
||||
export const TableSearchField = ({
|
||||
@ -17,9 +21,11 @@ export const TableSearchField = ({
|
||||
onChange,
|
||||
className,
|
||||
placeholder,
|
||||
onBlur,
|
||||
hasFilters,
|
||||
getSearchContext,
|
||||
}: ITableSearchFieldProps) => {
|
||||
const { classes: styles } = useStyles();
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@ -34,7 +40,6 @@ export const TableSearchField = ({
|
||||
className={classnames(styles.searchIcon, 'search-icon')}
|
||||
/>
|
||||
<InputBase
|
||||
autoFocus
|
||||
placeholder={placeholder}
|
||||
classes={{
|
||||
root: classnames(styles.inputRoot, 'input-container'),
|
||||
@ -42,7 +47,8 @@ export const TableSearchField = ({
|
||||
inputProps={{ 'aria-label': placeholder }}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onBlur={() => onBlur?.()}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onBlur={() => setShowSuggestions(false)}
|
||||
/>
|
||||
<div
|
||||
className={classnames(
|
||||
@ -58,7 +64,6 @@ export const TableSearchField = ({
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onChange('');
|
||||
onBlur?.(true);
|
||||
}}
|
||||
>
|
||||
<Close className={styles.clearIcon} />
|
||||
@ -68,6 +73,14 @@ export const TableSearchField = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(hasFilters) && showSuggestions}
|
||||
show={
|
||||
<TableSearchFieldSuggestions
|
||||
getSearchContext={getSearchContext!}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,72 @@
|
||||
import { styled } from '@mui/material';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import {
|
||||
getSearchTextGenerator,
|
||||
IGetSearchContextOutput,
|
||||
} from 'hooks/useSearch';
|
||||
import { VFC } from 'react';
|
||||
|
||||
const StyledHeader = styled('span')(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
color: theme.palette.text.primary,
|
||||
}));
|
||||
|
||||
const StyledCode = styled('span')(({ theme }) => ({
|
||||
backgroundColor: theme.palette.secondaryContainer,
|
||||
color: theme.palette.text.primary,
|
||||
padding: theme.spacing(0, 0.5),
|
||||
borderRadius: theme.spacing(0.5),
|
||||
}));
|
||||
|
||||
interface ISearchDescriptionProps {
|
||||
filters: any[];
|
||||
getSearchContext: () => IGetSearchContextOutput;
|
||||
searchableColumnsString: string;
|
||||
}
|
||||
|
||||
export const SearchDescription: VFC<ISearchDescriptionProps> = ({
|
||||
filters,
|
||||
getSearchContext,
|
||||
searchableColumnsString,
|
||||
}) => {
|
||||
const searchContext = getSearchContext();
|
||||
const getSearchText = getSearchTextGenerator(searchContext.columns);
|
||||
const searchText = getSearchText(searchContext.searchValue);
|
||||
const searchFilters = filters.filter(filter => filter.values.length > 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(searchText)}
|
||||
show={
|
||||
<>
|
||||
<StyledHeader>Searching for:</StyledHeader>
|
||||
<p>
|
||||
<StyledCode>{searchText}</StyledCode>{' '}
|
||||
{searchableColumnsString
|
||||
? ` in ${searchableColumnsString}`
|
||||
: ''}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={searchFilters.length > 0}
|
||||
show={
|
||||
<>
|
||||
<StyledHeader>Filtering by:</StyledHeader>
|
||||
{searchFilters.map(filter => (
|
||||
<p key={filter.name}>
|
||||
<StyledCode>
|
||||
{filter.values.join(',')}
|
||||
</StyledCode>{' '}
|
||||
in {filter.header}. Options:{' '}
|
||||
{filter.options.join(', ')}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,62 @@
|
||||
import { styled } from '@mui/material';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { IGetSearchContextOutput } from 'hooks/useSearch';
|
||||
import { VFC } from 'react';
|
||||
|
||||
const StyledHeader = styled('span')(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
color: theme.palette.text.primary,
|
||||
}));
|
||||
|
||||
const StyledCode = styled('span')(({ theme }) => ({
|
||||
backgroundColor: theme.palette.secondaryContainer,
|
||||
color: theme.palette.text.primary,
|
||||
padding: theme.spacing(0, 0.5),
|
||||
borderRadius: theme.spacing(0.5),
|
||||
}));
|
||||
|
||||
interface ISearchInstructionsProps {
|
||||
filters: any[];
|
||||
getSearchContext: () => IGetSearchContextOutput;
|
||||
searchableColumnsString: string;
|
||||
}
|
||||
|
||||
export const SearchInstructions: VFC<ISearchInstructionsProps> = ({
|
||||
filters,
|
||||
getSearchContext,
|
||||
searchableColumnsString,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<StyledHeader>
|
||||
{filters.length > 0
|
||||
? 'Filter your search with operators like:'
|
||||
: `Start typing to search${
|
||||
searchableColumnsString
|
||||
? ` in ${searchableColumnsString}`
|
||||
: '...'
|
||||
}`}
|
||||
</StyledHeader>
|
||||
{filters.map(filter => (
|
||||
<p key={filter.name}>
|
||||
Filter by {filter.header}:{' '}
|
||||
<StyledCode>
|
||||
{filter.name}:{filter.options[0]}
|
||||
</StyledCode>
|
||||
<ConditionallyRender
|
||||
condition={filter.options.length > 1}
|
||||
show={
|
||||
<>
|
||||
{' or '}
|
||||
<StyledCode>
|
||||
{filter.name}:
|
||||
{filter.options.slice(0, 2).join(',')}
|
||||
</StyledCode>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,150 @@
|
||||
import { FilterList } from '@mui/icons-material';
|
||||
import { Box, Divider, Paper, styled } from '@mui/material';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import {
|
||||
getColumnValues,
|
||||
getFilterableColumns,
|
||||
getFilterValues,
|
||||
IGetSearchContextOutput,
|
||||
} from 'hooks/useSearch';
|
||||
import { useMemo, VFC } from 'react';
|
||||
import { SearchDescription } from './SearchDescription/SearchDescription';
|
||||
import { SearchInstructions } from './SearchInstructions/SearchInstructions';
|
||||
|
||||
const randomIndex = (arr: any[]) => Math.floor(Math.random() * arr.length);
|
||||
|
||||
const StyledPaper = styled(Paper)(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
left: 0,
|
||||
top: '20px',
|
||||
zIndex: 2,
|
||||
padding: theme.spacing(4, 1.5, 1.5),
|
||||
borderBottomLeftRadius: theme.spacing(1),
|
||||
borderBottomRightRadius: theme.spacing(1),
|
||||
boxShadow: '0px 8px 20px rgba(33, 33, 33, 0.15)',
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
color: theme.palette.text.secondary,
|
||||
wordBreak: 'break-word',
|
||||
}));
|
||||
|
||||
const StyledBox = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
gap: theme.spacing(2),
|
||||
}));
|
||||
|
||||
const StyledFilterList = styled(FilterList)(({ theme }) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
}));
|
||||
|
||||
const StyledDivider = styled(Divider)(({ theme }) => ({
|
||||
border: `1px dashed ${theme.palette.dividerAlternative}`,
|
||||
margin: theme.spacing(1.5, 0),
|
||||
}));
|
||||
|
||||
const StyledCode = styled('span')(({ theme }) => ({
|
||||
backgroundColor: theme.palette.secondaryContainer,
|
||||
color: theme.palette.text.primary,
|
||||
padding: theme.spacing(0, 0.5),
|
||||
borderRadius: theme.spacing(0.5),
|
||||
}));
|
||||
|
||||
interface TableSearchFieldSuggestionsProps {
|
||||
getSearchContext: () => IGetSearchContextOutput;
|
||||
}
|
||||
|
||||
export const TableSearchFieldSuggestions: VFC<
|
||||
TableSearchFieldSuggestionsProps
|
||||
> = ({ getSearchContext }) => {
|
||||
const searchContext = getSearchContext();
|
||||
|
||||
const randomRow = useMemo(
|
||||
() => randomIndex(searchContext.data),
|
||||
[searchContext.data]
|
||||
);
|
||||
|
||||
const filters = getFilterableColumns(searchContext.columns)
|
||||
.map(column => {
|
||||
const filterOptions = searchContext.data.map(row =>
|
||||
getColumnValues(column, row)
|
||||
);
|
||||
|
||||
return {
|
||||
name: column.filterName,
|
||||
header: column.Header ?? column.filterName,
|
||||
options: [...new Set(filterOptions)].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
),
|
||||
suggestedOption:
|
||||
filterOptions[randomRow] ?? `example-${column.filterName}`,
|
||||
values: getFilterValues(
|
||||
column.filterName,
|
||||
searchContext.searchValue
|
||||
),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const searchableColumns = searchContext.columns.filter(
|
||||
column => column.searchable && column.accessor
|
||||
);
|
||||
|
||||
const searchableColumnsString = searchableColumns
|
||||
.map(column => column.Header ?? column.accessor)
|
||||
.join(', ');
|
||||
|
||||
const suggestedTextSearch =
|
||||
searchContext.data.length && searchableColumns.length
|
||||
? getColumnValues(
|
||||
searchableColumns[0],
|
||||
searchContext.data[randomRow]
|
||||
)
|
||||
: 'example-search-text';
|
||||
|
||||
return (
|
||||
<StyledPaper>
|
||||
<StyledBox>
|
||||
<StyledFilterList />
|
||||
<Box>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(searchContext.searchValue)}
|
||||
show={
|
||||
<SearchDescription
|
||||
filters={filters}
|
||||
getSearchContext={getSearchContext}
|
||||
searchableColumnsString={
|
||||
searchableColumnsString
|
||||
}
|
||||
/>
|
||||
}
|
||||
elseShow={
|
||||
<SearchInstructions
|
||||
filters={filters}
|
||||
getSearchContext={getSearchContext}
|
||||
searchableColumnsString={
|
||||
searchableColumnsString
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</StyledBox>
|
||||
<StyledDivider />
|
||||
<ConditionallyRender
|
||||
condition={filters.length > 0}
|
||||
show="Combine filters and search."
|
||||
/>
|
||||
<p>
|
||||
Example:{' '}
|
||||
<StyledCode>
|
||||
{filters.map(filter => (
|
||||
<span key={filter.name}>
|
||||
{filter.name}:{filter.suggestedOption}{' '}
|
||||
</span>
|
||||
))}
|
||||
<span>{suggestedTextSearch}</span>
|
||||
</StyledCode>
|
||||
</p>
|
||||
</StyledPaper>
|
||||
);
|
||||
};
|
@ -1,13 +1,7 @@
|
||||
import { useEffect, useMemo, useState, VFC } from 'react';
|
||||
import { Link, useMediaQuery, useTheme } from '@mui/material';
|
||||
import { Link as RouterLink, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
SortingRule,
|
||||
useFlexLayout,
|
||||
useGlobalFilter,
|
||||
useSortBy,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
|
||||
import {
|
||||
Table,
|
||||
SortableTableHeader,
|
||||
@ -34,6 +28,7 @@ import { FeatureSchema } from 'openapi';
|
||||
import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton';
|
||||
import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell';
|
||||
import { useStyles } from './styles';
|
||||
import { useSearch } from 'hooks/useSearch';
|
||||
|
||||
const featuresPlaceholder: FeatureSchema[] = Array(15).fill({
|
||||
name: 'Name of the feature',
|
||||
@ -53,7 +48,6 @@ const columns = [
|
||||
sortType: 'date',
|
||||
align: 'center',
|
||||
maxWidth: 85,
|
||||
disableGlobalFilter: true,
|
||||
},
|
||||
{
|
||||
Header: 'Type',
|
||||
@ -61,7 +55,6 @@ const columns = [
|
||||
Cell: FeatureTypeCell,
|
||||
align: 'center',
|
||||
maxWidth: 85,
|
||||
disableGlobalFilter: true,
|
||||
},
|
||||
{
|
||||
Header: 'Name',
|
||||
@ -69,6 +62,7 @@ const columns = [
|
||||
minWidth: 150,
|
||||
Cell: FeatureNameCell,
|
||||
sortType: 'alphanumeric',
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
Header: 'Created',
|
||||
@ -76,7 +70,6 @@ const columns = [
|
||||
Cell: DateCell,
|
||||
sortType: 'date',
|
||||
maxWidth: 150,
|
||||
disableGlobalFilter: true,
|
||||
},
|
||||
{
|
||||
Header: 'Project ID',
|
||||
@ -86,6 +79,8 @@ const columns = [
|
||||
),
|
||||
sortType: 'alphanumeric',
|
||||
maxWidth: 150,
|
||||
filterName: 'project',
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
Header: 'State',
|
||||
@ -93,7 +88,8 @@ const columns = [
|
||||
Cell: FeatureStaleCell,
|
||||
sortType: 'boolean',
|
||||
maxWidth: 120,
|
||||
disableGlobalFilter: true,
|
||||
filterName: 'state',
|
||||
filterParsing: (value: any) => (value ? 'stale' : 'active'),
|
||||
},
|
||||
// Always hidden -- for search
|
||||
{
|
||||
@ -115,10 +111,22 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
defaultSort
|
||||
);
|
||||
const { features = [], loading } = useFeatures();
|
||||
const [searchValue, setSearchValue] = useState(
|
||||
searchParams.get('search') || ''
|
||||
);
|
||||
|
||||
const {
|
||||
data: searchedData,
|
||||
getSearchText,
|
||||
getSearchContext,
|
||||
} = useSearch(columns, searchValue, features);
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
features?.length === 0 && loading ? featuresPlaceholder : features,
|
||||
[features, loading]
|
||||
searchedData?.length === 0 && loading
|
||||
? featuresPlaceholder
|
||||
: searchedData,
|
||||
[searchedData, loading]
|
||||
);
|
||||
|
||||
const [initialState] = useState(() => ({
|
||||
@ -131,7 +139,6 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
},
|
||||
],
|
||||
hiddenColumns: ['description'],
|
||||
globalFilter: searchParams.get('search') || '',
|
||||
}));
|
||||
|
||||
const {
|
||||
@ -140,23 +147,18 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
state: { globalFilter, sortBy },
|
||||
setGlobalFilter,
|
||||
state: { sortBy },
|
||||
setHiddenColumns,
|
||||
} = useTable(
|
||||
{
|
||||
// @ts-expect-error -- fix in react-table v8
|
||||
columns,
|
||||
// @ts-expect-error -- fix in react-table v8
|
||||
data,
|
||||
initialState,
|
||||
sortTypes,
|
||||
autoResetGlobalFilter: false,
|
||||
autoResetSortBy: false,
|
||||
disableSortRemove: true,
|
||||
disableMultiSort: true,
|
||||
},
|
||||
useGlobalFilter,
|
||||
useSortBy,
|
||||
useFlexLayout
|
||||
);
|
||||
@ -178,15 +180,15 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
if (sortBy[0].desc) {
|
||||
tableState.order = 'desc';
|
||||
}
|
||||
if (globalFilter) {
|
||||
tableState.search = globalFilter;
|
||||
if (searchValue) {
|
||||
tableState.search = searchValue;
|
||||
}
|
||||
|
||||
setSearchParams(tableState, {
|
||||
replace: true,
|
||||
});
|
||||
setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false });
|
||||
}, [sortBy, globalFilter, setSearchParams, setStoredParams]);
|
||||
}, [sortBy, searchValue, setSearchParams, setStoredParams]);
|
||||
|
||||
const [firstRenderedIndex, lastRenderedIndex] =
|
||||
useVirtualizedRange(rowHeight);
|
||||
@ -203,16 +205,25 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
})`}
|
||||
actions={
|
||||
<>
|
||||
<TableSearch
|
||||
initialValue={globalFilter}
|
||||
onChange={setGlobalFilter}
|
||||
<ConditionallyRender
|
||||
condition={!isSmallScreen}
|
||||
show={
|
||||
<>
|
||||
<TableSearch
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
hasFilters
|
||||
getSearchContext={getSearchContext}
|
||||
/>
|
||||
<PageHeader.Divider />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<PageHeader.Divider />
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/archive"
|
||||
underline="always"
|
||||
sx={{ marginRight: 3 }}
|
||||
sx={{ marginRight: 2 }}
|
||||
>
|
||||
View archive
|
||||
</Link>
|
||||
@ -222,12 +233,23 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={isSmallScreen}
|
||||
show={
|
||||
<TableSearch
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
hasFilters
|
||||
getSearchContext={getSearchContext}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageHeader>
|
||||
}
|
||||
>
|
||||
<SearchHighlightProvider value={globalFilter}>
|
||||
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
||||
<Table {...getTableProps()} rowHeight={rowHeight}>
|
||||
{/* @ts-expect-error -- fix in react-table v8 */}
|
||||
<SortableTableHeader headerGroups={headerGroups} flex />
|
||||
<TableBody
|
||||
{...getTableBodyProps()}
|
||||
@ -281,11 +303,11 @@ export const FeatureToggleListTable: VFC = () => {
|
||||
condition={rows.length === 0}
|
||||
show={
|
||||
<ConditionallyRender
|
||||
condition={globalFilter?.length > 0}
|
||||
condition={searchValue?.length > 0}
|
||||
show={
|
||||
<TablePlaceholder>
|
||||
No feature toggles found matching “
|
||||
{globalFilter}
|
||||
{searchValue}
|
||||
”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
|
@ -28,9 +28,6 @@ export const useStyles = makeStyles()(theme => ({
|
||||
},
|
||||
title: {
|
||||
display: 'unset',
|
||||
[theme.breakpoints.down(600)]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
iconButton: {
|
||||
marginRight: '1rem',
|
||||
|
@ -2,13 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTheme } from '@mui/system';
|
||||
import { Add } from '@mui/icons-material';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
useGlobalFilter,
|
||||
useFlexLayout,
|
||||
useSortBy,
|
||||
useTable,
|
||||
SortingRule,
|
||||
} from 'react-table';
|
||||
import { useFlexLayout, useSortBy, useTable, SortingRule } from 'react-table';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
@ -48,6 +42,8 @@ import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu';
|
||||
import { useStyles } from './ProjectFeatureToggles.styles';
|
||||
import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog';
|
||||
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
|
||||
import { useSearch } from 'hooks/useSearch';
|
||||
import { useMediaQuery } from '@mui/material';
|
||||
|
||||
interface IProjectFeatureTogglesProps {
|
||||
features: IProject['features'];
|
||||
@ -82,6 +78,8 @@ export const ProjectFeatureToggles = ({
|
||||
environments: newEnvironments = [],
|
||||
}: IProjectFeatureTogglesProps) => {
|
||||
const { classes: styles } = useStyles();
|
||||
const theme = useTheme();
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const [strategiesDialogState, setStrategiesDialogState] = useState({
|
||||
open: false,
|
||||
featureId: '',
|
||||
@ -102,53 +100,11 @@ export const ProjectFeatureToggles = ({
|
||||
);
|
||||
const { refetch } = useProject(projectId);
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const theme = useTheme();
|
||||
const rowHeight = theme.shape.tableRowHeight;
|
||||
|
||||
const data = useMemo<ListItemType[]>(() => {
|
||||
if (loading) {
|
||||
return Array(6).fill({
|
||||
type: '-',
|
||||
name: 'Feature name',
|
||||
createdAt: new Date(),
|
||||
environments: {
|
||||
production: { name: 'production', enabled: false },
|
||||
},
|
||||
}) as ListItemType[];
|
||||
}
|
||||
|
||||
return features.map(
|
||||
({
|
||||
name,
|
||||
lastSeenAt,
|
||||
createdAt,
|
||||
type,
|
||||
stale,
|
||||
environments: featureEnvironments,
|
||||
}) => ({
|
||||
name,
|
||||
lastSeenAt,
|
||||
createdAt,
|
||||
type,
|
||||
stale,
|
||||
environments: Object.fromEntries(
|
||||
environments.map(env => [
|
||||
env,
|
||||
{
|
||||
name: env,
|
||||
enabled:
|
||||
featureEnvironments?.find(
|
||||
feature => feature?.name === env
|
||||
)?.enabled || false,
|
||||
},
|
||||
])
|
||||
),
|
||||
})
|
||||
);
|
||||
}, [features, loading]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const { toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } =
|
||||
useFeatureApi();
|
||||
|
||||
const onToggle = useCallback(
|
||||
async (
|
||||
projectId: string,
|
||||
@ -223,7 +179,7 @@ export const ProjectFeatureToggles = ({
|
||||
),
|
||||
minWidth: 100,
|
||||
sortType: 'alphanumeric',
|
||||
disableGlobalFilter: false,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
Header: 'Created',
|
||||
@ -257,6 +213,9 @@ export const ProjectFeatureToggles = ({
|
||||
const b = v2?.values?.[id]?.enabled;
|
||||
return a === b ? 0 : a ? -1 : 1;
|
||||
},
|
||||
filterName: name,
|
||||
filterParsing: (value: any) =>
|
||||
value.enabled ? 'enabled' : 'disabled',
|
||||
})),
|
||||
{
|
||||
id: 'Actions',
|
||||
@ -275,12 +234,70 @@ export const ProjectFeatureToggles = ({
|
||||
],
|
||||
[projectId, environments, onToggle, loading]
|
||||
);
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [storedParams, setStoredParams] = useLocalStorage(
|
||||
`${projectId}:ProjectFeatureToggles`,
|
||||
defaultSort
|
||||
);
|
||||
|
||||
const [searchValue, setSearchValue] = useState(
|
||||
searchParams.get('search') || ''
|
||||
);
|
||||
|
||||
const featuresData = useMemo(
|
||||
() =>
|
||||
features.map(
|
||||
({
|
||||
name,
|
||||
lastSeenAt,
|
||||
createdAt,
|
||||
type,
|
||||
stale,
|
||||
environments: featureEnvironments,
|
||||
}) => ({
|
||||
name,
|
||||
lastSeenAt,
|
||||
createdAt,
|
||||
type,
|
||||
stale,
|
||||
environments: Object.fromEntries(
|
||||
environments.map(env => [
|
||||
env,
|
||||
{
|
||||
name: env,
|
||||
enabled:
|
||||
featureEnvironments?.find(
|
||||
feature => feature?.name === env
|
||||
)?.enabled || false,
|
||||
},
|
||||
])
|
||||
),
|
||||
})
|
||||
),
|
||||
[features, environments]
|
||||
);
|
||||
|
||||
const {
|
||||
data: searchedData,
|
||||
getSearchText,
|
||||
getSearchContext,
|
||||
} = useSearch(columns, searchValue, featuresData);
|
||||
|
||||
const data = useMemo<ListItemType[]>(() => {
|
||||
if (loading) {
|
||||
return Array(6).fill({
|
||||
type: '-',
|
||||
name: 'Feature name',
|
||||
createdAt: new Date(),
|
||||
environments: {
|
||||
production: { name: 'production', enabled: false },
|
||||
},
|
||||
}) as ListItemType[];
|
||||
}
|
||||
return searchedData;
|
||||
}, [loading, searchedData]);
|
||||
|
||||
const initialState = useMemo(
|
||||
() => {
|
||||
const allColumnIds = columns.map(
|
||||
@ -317,7 +334,6 @@ export const ProjectFeatureToggles = ({
|
||||
},
|
||||
],
|
||||
hiddenColumns,
|
||||
globalFilter: searchParams.get('search') || '',
|
||||
};
|
||||
},
|
||||
[environments] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@ -327,11 +343,10 @@ export const ProjectFeatureToggles = ({
|
||||
allColumns,
|
||||
headerGroups,
|
||||
rows,
|
||||
state: { globalFilter, sortBy, hiddenColumns },
|
||||
state: { sortBy, hiddenColumns },
|
||||
getTableBodyProps,
|
||||
getTableProps,
|
||||
prepareRow,
|
||||
setGlobalFilter,
|
||||
setHiddenColumns,
|
||||
} = useTable(
|
||||
{
|
||||
@ -339,15 +354,10 @@ export const ProjectFeatureToggles = ({
|
||||
data,
|
||||
initialState,
|
||||
sortTypes,
|
||||
autoResetGlobalFilter: false,
|
||||
disableSortRemove: true,
|
||||
autoResetSortBy: false,
|
||||
defaultColumn: {
|
||||
disableGlobalFilter: true,
|
||||
},
|
||||
},
|
||||
useFlexLayout,
|
||||
useGlobalFilter,
|
||||
useSortBy
|
||||
);
|
||||
|
||||
@ -360,8 +370,8 @@ export const ProjectFeatureToggles = ({
|
||||
if (sortBy[0].desc) {
|
||||
tableState.order = 'desc';
|
||||
}
|
||||
if (globalFilter) {
|
||||
tableState.search = globalFilter;
|
||||
if (searchValue) {
|
||||
tableState.search = searchValue;
|
||||
}
|
||||
tableState.columns = allColumns
|
||||
.map(({ id }) => id)
|
||||
@ -380,7 +390,7 @@ export const ProjectFeatureToggles = ({
|
||||
columns: tableState.columns.split(','),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, sortBy, hiddenColumns, globalFilter, setSearchParams]);
|
||||
}, [loading, sortBy, hiddenColumns, searchValue, setSearchParams]);
|
||||
|
||||
const onCustomizeColumns = useCallback(
|
||||
visibleColumns => {
|
||||
@ -404,12 +414,21 @@ export const ProjectFeatureToggles = ({
|
||||
header={
|
||||
<PageHeader
|
||||
className={styles.title}
|
||||
title={`Project feature toggles (${rows.length})`}
|
||||
title={`Feature toggles (${rows.length})`}
|
||||
actions={
|
||||
<>
|
||||
<TableSearch
|
||||
initialValue={globalFilter}
|
||||
onChange={value => setGlobalFilter(value)}
|
||||
<ConditionallyRender
|
||||
condition={!isSmallScreen}
|
||||
show={
|
||||
<TableSearch
|
||||
initialValue={searchValue}
|
||||
onChange={value =>
|
||||
setSearchValue(value)
|
||||
}
|
||||
hasFilters
|
||||
getSearchContext={getSearchContext}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ColumnsMenu
|
||||
allColumns={allColumns}
|
||||
@ -420,7 +439,7 @@ export const ProjectFeatureToggles = ({
|
||||
onCustomize={onCustomizeColumns}
|
||||
setHiddenColumns={setHiddenColumns}
|
||||
/>
|
||||
<PageHeader.Divider />
|
||||
<PageHeader.Divider sx={{ marginLeft: 0 }} />
|
||||
<ResponsiveButton
|
||||
onClick={() =>
|
||||
navigate(
|
||||
@ -430,7 +449,7 @@ export const ProjectFeatureToggles = ({
|
||||
)
|
||||
)
|
||||
}
|
||||
maxWidth="700px"
|
||||
maxWidth="960px"
|
||||
Icon={Add}
|
||||
projectId={projectId}
|
||||
permission={CREATE_FEATURE}
|
||||
@ -440,10 +459,22 @@ export const ProjectFeatureToggles = ({
|
||||
</ResponsiveButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={isSmallScreen}
|
||||
show={
|
||||
<TableSearch
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
hasFilters
|
||||
getSearchContext={getSearchContext}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageHeader>
|
||||
}
|
||||
>
|
||||
<SearchHighlightProvider value={globalFilter}>
|
||||
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
||||
<Table {...getTableProps()} rowHeight={rowHeight}>
|
||||
<SortableTableHeader
|
||||
// @ts-expect-error -- verify after `react-table` v8
|
||||
@ -502,11 +533,11 @@ export const ProjectFeatureToggles = ({
|
||||
condition={rows.length === 0}
|
||||
show={
|
||||
<ConditionallyRender
|
||||
condition={globalFilter?.length > 0}
|
||||
condition={searchValue?.length > 0}
|
||||
show={
|
||||
<TablePlaceholder>
|
||||
No feature toggles found matching “
|
||||
{globalFilter}
|
||||
{searchValue}
|
||||
”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
|
@ -31,46 +31,46 @@ exports[`renders an empty list correctly 1`] = `
|
||||
<div
|
||||
className="tss-u5t8ea-headerActions"
|
||||
>
|
||||
<button
|
||||
aria-label="Search"
|
||||
aria-labelledby={null}
|
||||
className="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeLarge tss-15hjuf9-searchButton mui-mf1cb5-MuiButtonBase-root-MuiIconButton-root"
|
||||
data-mui-internal-clone-element={true}
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onContextMenu={[Function]}
|
||||
onDragLeave={[Function]}
|
||||
onFocus={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onKeyUp={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseLeave={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onMouseUp={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
onTouchMove={[Function]}
|
||||
onTouchStart={[Function]}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
<div
|
||||
className="tss-119iiqp-container"
|
||||
>
|
||||
<svg
|
||||
aria-hidden={true}
|
||||
className="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-i4bv87-MuiSvgIcon-root"
|
||||
data-testid="SearchIcon"
|
||||
focusable="false"
|
||||
viewBox="0 0 24 24"
|
||||
<div
|
||||
className="tss-1mtd8gr-search search-container"
|
||||
>
|
||||
<path
|
||||
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
|
||||
<svg
|
||||
aria-hidden={true}
|
||||
className="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium tss-1ehjh85-searchIcon search-icon mui-i4bv87-MuiSvgIcon-root"
|
||||
data-testid="SearchIcon"
|
||||
focusable="false"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
className="tss-11gf6cf-inputRoot input-container MuiInputBase-root MuiInputBase-colorPrimary mui-1v3pvzz-MuiInputBase-root"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<input
|
||||
aria-label="Search…"
|
||||
className="MuiInputBase-input mui-j79lc6-MuiInputBase-input"
|
||||
onAnimationStart={[Function]}
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
placeholder="Search…"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="tss-llttyo-clearContainer clear-container"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className="MuiTouchRipple-root mui-8je8zh-MuiTouchRipple-root"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr
|
||||
className="MuiDivider-root MuiDivider-middle MuiDivider-vertical tss-a0frlx-verticalSeparator mui-oezrwv-MuiDivider-root"
|
||||
className="MuiDivider-root MuiDivider-middle MuiDivider-vertical mui-1p1j01b-MuiDivider-root"
|
||||
/>
|
||||
<span
|
||||
id="useId-0"
|
||||
|
312
frontend/src/hooks/useSearch.test.ts
Normal file
312
frontend/src/hooks/useSearch.test.ts
Normal file
@ -0,0 +1,312 @@
|
||||
import {
|
||||
isValidFilter,
|
||||
getSearchTextGenerator,
|
||||
searchInFilteredData,
|
||||
filter,
|
||||
} from './useSearch';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
accessor: 'name',
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
accessor: (row: any) => row.project,
|
||||
filterName: 'project',
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
accessor: 'stale',
|
||||
filterName: 'state',
|
||||
filterBy: (row: any, values: string[]) =>
|
||||
(values.includes('active') && !row.stale) ||
|
||||
(values.includes('stale') && row.stale),
|
||||
},
|
||||
{
|
||||
accessor: (row: any) => row.type,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
accessor: 'seen',
|
||||
searchable: true,
|
||||
searchBy: (row: any, value: string) =>
|
||||
(value === 'seen' && row.seen) || (value === 'never' && !row.seen),
|
||||
},
|
||||
];
|
||||
|
||||
const data = [
|
||||
{
|
||||
name: 'my-feature-toggle',
|
||||
project: 'default',
|
||||
stale: false,
|
||||
type: 'release',
|
||||
seen: true,
|
||||
},
|
||||
{
|
||||
name: 'my-feature-toggle-2',
|
||||
project: 'default',
|
||||
stale: true,
|
||||
type: 'experiment',
|
||||
seen: false,
|
||||
},
|
||||
{
|
||||
name: 'my-feature-toggle-3',
|
||||
project: 'my-project',
|
||||
stale: false,
|
||||
type: 'operational',
|
||||
seen: false,
|
||||
},
|
||||
{
|
||||
name: 'my-feature-toggle-4',
|
||||
project: 'my-project',
|
||||
stale: true,
|
||||
type: 'permission',
|
||||
seen: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe('isValidFilter', () => {
|
||||
it('should accept a filter with a value', () => {
|
||||
const input = 'project:default';
|
||||
const match = 'project';
|
||||
|
||||
const result = isValidFilter(input, match);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should not accept a filter without a value', () => {
|
||||
const input = 'project:';
|
||||
const match = 'project';
|
||||
|
||||
const result = isValidFilter(input, match);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when match is not included in search string', () => {
|
||||
const input = 'project:default';
|
||||
const match = 'state';
|
||||
|
||||
const result = isValidFilter(input, match);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSearchText', () => {
|
||||
const getSearchText = getSearchTextGenerator(columns);
|
||||
|
||||
it('should return search value without filters', () => {
|
||||
const tests = [
|
||||
{ input: 'project:textsearch default', expectation: 'default' },
|
||||
{
|
||||
input: 'project:default state:active feature-toggle',
|
||||
expectation: 'feature-toggle',
|
||||
},
|
||||
{ input: 'project:default', expectation: '' },
|
||||
{ input: '', expectation: '' },
|
||||
{ input: 'a', expectation: 'a' },
|
||||
{ input: 'a:', expectation: 'a:' },
|
||||
{ input: 'my-feature:test', expectation: 'my-feature:test' },
|
||||
{
|
||||
input: 'my-new-feature-toggle project:defaultstate:active',
|
||||
expectation: 'my-new-feature-toggle',
|
||||
},
|
||||
{
|
||||
input: 'my-new-feature-toggle project:default state:active',
|
||||
expectation: 'my-new-feature-toggle',
|
||||
},
|
||||
];
|
||||
|
||||
tests.forEach(test => {
|
||||
const result = getSearchText(test.input);
|
||||
expect(result).toBe(test.expectation);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return search value without multiple filters', () => {
|
||||
const input = 'project:default state:active feature-toggle';
|
||||
const result = getSearchText(input);
|
||||
|
||||
expect(result).toBe('feature-toggle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchInFilteredData', () => {
|
||||
it('should search in searchable columns', () => {
|
||||
const tests = [
|
||||
{
|
||||
input: 'project',
|
||||
expectation: [
|
||||
{
|
||||
name: 'my-feature-toggle-3',
|
||||
project: 'my-project',
|
||||
stale: false,
|
||||
type: 'operational',
|
||||
seen: false,
|
||||
},
|
||||
{
|
||||
name: 'my-feature-toggle-4',
|
||||
project: 'my-project',
|
||||
stale: true,
|
||||
type: 'permission',
|
||||
seen: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
input: 'toggle-2',
|
||||
expectation: [
|
||||
{
|
||||
name: 'my-feature-toggle-2',
|
||||
project: 'default',
|
||||
stale: true,
|
||||
type: 'experiment',
|
||||
seen: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
input: 'non-existing-toggle',
|
||||
expectation: [],
|
||||
},
|
||||
];
|
||||
|
||||
tests.forEach(test => {
|
||||
const result = searchInFilteredData(columns, test.input, data);
|
||||
expect(result).toEqual(test.expectation);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use column accessor function to search when defined', () => {
|
||||
const result = searchInFilteredData(columns, 'experiment', data);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: 'my-feature-toggle-2',
|
||||
project: 'default',
|
||||
stale: true,
|
||||
type: 'experiment',
|
||||
seen: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use custom search function to search when defined', () => {
|
||||
const result = searchInFilteredData(columns, 'never', data);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: 'my-feature-toggle-2',
|
||||
project: 'default',
|
||||
stale: true,
|
||||
type: 'experiment',
|
||||
seen: false,
|
||||
},
|
||||
{
|
||||
name: 'my-feature-toggle-3',
|
||||
project: 'my-project',
|
||||
stale: false,
|
||||
type: 'operational',
|
||||
seen: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter', () => {
|
||||
it('should filter in filterable columns', () => {
|
||||
const tests = [
|
||||
{
|
||||
input: 'project:default',
|
||||
expectation: [
|
||||
{
|
||||
name: 'my-feature-toggle',
|
||||
project: 'default',
|
||||
stale: false,
|
||||
type: 'release',
|
||||
seen: true,
|
||||
},
|
||||
{
|
||||
name: 'my-feature-toggle-2',
|
||||
project: 'default',
|
||||
stale: true,
|
||||
type: 'experiment',
|
||||
seen: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
input: 'state:active',
|
||||
expectation: [
|
||||
{
|
||||
name: 'my-feature-toggle',
|
||||
project: 'default',
|
||||
stale: false,
|
||||
type: 'release',
|
||||
seen: true,
|
||||
},
|
||||
{
|
||||
name: 'my-feature-toggle-3',
|
||||
project: 'my-project',
|
||||
stale: false,
|
||||
type: 'operational',
|
||||
seen: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
input: 'state:something-else',
|
||||
expectation: [],
|
||||
},
|
||||
];
|
||||
|
||||
tests.forEach(test => {
|
||||
const result = filter(columns, test.input, data);
|
||||
expect(result).toEqual(test.expectation);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use column accessor function to filter when defined', () => {
|
||||
const result = filter(columns, 'project:my-project', data);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: 'my-feature-toggle-3',
|
||||
project: 'my-project',
|
||||
stale: false,
|
||||
type: 'operational',
|
||||
seen: false,
|
||||
},
|
||||
{
|
||||
name: 'my-feature-toggle-4',
|
||||
project: 'my-project',
|
||||
stale: true,
|
||||
type: 'permission',
|
||||
seen: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use custom filter function to filter when defined', () => {
|
||||
const result = filter(columns, 'state:stale', data);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: 'my-feature-toggle-2',
|
||||
project: 'default',
|
||||
stale: true,
|
||||
type: 'experiment',
|
||||
seen: false,
|
||||
},
|
||||
{
|
||||
name: 'my-feature-toggle-4',
|
||||
project: 'my-project',
|
||||
stale: true,
|
||||
type: 'permission',
|
||||
seen: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
130
frontend/src/hooks/useSearch.ts
Normal file
130
frontend/src/hooks/useSearch.ts
Normal file
@ -0,0 +1,130 @@
|
||||
interface IUseSearchOutput {
|
||||
getSearchText: (input: string) => string;
|
||||
data: any[];
|
||||
getSearchContext: () => IGetSearchContextOutput;
|
||||
}
|
||||
|
||||
export interface IGetSearchContextOutput {
|
||||
data: any[];
|
||||
columns: any[];
|
||||
searchValue: string;
|
||||
}
|
||||
|
||||
export const useSearch = (
|
||||
columns: any[],
|
||||
searchValue: string,
|
||||
data: any[]
|
||||
): IUseSearchOutput => {
|
||||
const getSearchText = getSearchTextGenerator(columns);
|
||||
|
||||
const getSearchContext = () => {
|
||||
return { data, searchValue, columns };
|
||||
};
|
||||
|
||||
if (!searchValue) return { data, getSearchText, getSearchContext };
|
||||
|
||||
const search = () => {
|
||||
const filteredData = filter(columns, searchValue, data);
|
||||
const searchedData = searchInFilteredData(
|
||||
columns,
|
||||
getSearchText(searchValue),
|
||||
filteredData
|
||||
);
|
||||
|
||||
return searchedData;
|
||||
};
|
||||
|
||||
return { data: search(), getSearchText, getSearchContext };
|
||||
};
|
||||
|
||||
export const filter = (columns: any[], searchValue: string, data: any[]) => {
|
||||
let filteredDataSet = data;
|
||||
|
||||
getFilterableColumns(columns)
|
||||
.filter(column => isValidFilter(searchValue, column.filterName))
|
||||
.forEach(column => {
|
||||
const values = getFilterValues(column.filterName, searchValue);
|
||||
|
||||
filteredDataSet = filteredDataSet.filter(row => {
|
||||
if (column.filterBy) {
|
||||
return column.filterBy(row, values);
|
||||
}
|
||||
|
||||
return defaultFilter(getColumnValues(column, row), values);
|
||||
});
|
||||
});
|
||||
|
||||
return filteredDataSet;
|
||||
};
|
||||
|
||||
export const searchInFilteredData = (
|
||||
columns: any[],
|
||||
searchValue: string,
|
||||
filteredData: any[]
|
||||
) => {
|
||||
const searchableColumns = columns.filter(
|
||||
column => column.searchable && column.accessor
|
||||
);
|
||||
|
||||
return filteredData.filter(row => {
|
||||
return searchableColumns.some(column => {
|
||||
if (column.searchBy) {
|
||||
return column.searchBy(row, searchValue);
|
||||
}
|
||||
|
||||
return defaultSearch(getColumnValues(column, row), searchValue);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const defaultFilter = (fieldValue: string, values: string[]) =>
|
||||
values.some(value => fieldValue?.toLowerCase() === value?.toLowerCase());
|
||||
|
||||
const defaultSearch = (fieldValue: string, value: string) =>
|
||||
fieldValue?.toLowerCase().includes(value?.toLowerCase());
|
||||
|
||||
export const getSearchTextGenerator = (columns: any[]) => {
|
||||
const filters = columns
|
||||
.filter(column => column.filterName)
|
||||
.map(column => column.filterName);
|
||||
|
||||
const isValidSearch = (fragment: string) => {
|
||||
return filters.some(filter => isValidFilter(fragment, filter));
|
||||
};
|
||||
|
||||
return (searchValue: string) =>
|
||||
searchValue
|
||||
.split(' ')
|
||||
.filter(fragment => !isValidSearch(fragment))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
export const isValidFilter = (input: string, match: string) =>
|
||||
new RegExp(`${match}:\\w+`).test(input);
|
||||
|
||||
export const getFilterableColumns = (columns: any[]) =>
|
||||
columns.filter(column => column.filterName && column.accessor);
|
||||
|
||||
export const getColumnValues = (column: any, row: any) => {
|
||||
const value =
|
||||
typeof column.accessor === 'function'
|
||||
? column.accessor(row)
|
||||
: column.accessor.includes('.')
|
||||
? column.accessor
|
||||
.split('.')
|
||||
.reduce((object: any, key: string) => object[key], row)
|
||||
: row[column.accessor];
|
||||
|
||||
if (column.filterParsing) {
|
||||
return column.filterParsing(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const getFilterValues = (filterName: string, searchValue: string) =>
|
||||
searchValue
|
||||
?.split(`${filterName}:`)[1]
|
||||
?.split(' ')[0]
|
||||
?.split(',')
|
||||
.filter(value => value) ?? [];
|
Loading…
Reference in New Issue
Block a user