1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-23 01:16:27 +02: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:
Nuno Góis 2022-06-03 11:32:30 +01:00 committed by GitHub
parent 32ada96220
commit 4a5ed3c3e7
15 changed files with 983 additions and 285 deletions

View File

@ -28,14 +28,4 @@ export const useStyles = makeStyles()(theme => ({
alignItems: 'center', alignItems: 'center',
gap: theme.spacing(1), 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',
},
})); }));

View File

@ -1,12 +1,30 @@
import { ReactNode, FC, VFC } from 'react'; import { ReactNode, FC, VFC } from 'react';
import classnames from 'classnames'; 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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useStyles } from './PageHeader.styles'; import { useStyles } from './PageHeader.styles';
import { usePageTitle } from 'hooks/usePageTitle'; 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 { interface IPageHeaderProps {
title: string; title: string;
titleElement?: ReactNode; titleElement?: ReactNode;
@ -17,7 +35,9 @@ interface IPageHeaderProps {
className?: string; className?: string;
} }
const PageHeaderComponent: FC<IPageHeaderProps> & { Divider: VFC } = ({ const PageHeaderComponent: FC<IPageHeaderProps> & {
Divider: typeof PageHeaderDivider;
} = ({
title, title,
titleElement, titleElement,
actions, actions,
@ -57,16 +77,8 @@ const PageHeaderComponent: FC<IPageHeaderProps> & { Divider: VFC } = ({
); );
}; };
const PageHeaderDivider: VFC = () => { const PageHeaderDivider: VFC<{ sx?: SxProps<Theme> }> = ({ sx }) => {
const { classes: styles } = useStyles(); return <StyledDivider orientation="vertical" variant="middle" sx={sx} />;
return (
<Divider
orientation="vertical"
variant="middle"
className={styles.verticalSeparator}
/>
);
}; };
PageHeaderComponent.Divider = PageHeaderDivider; PageHeaderComponent.Divider = PageHeaderDivider;

View File

@ -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',
},
}));

View File

@ -1,75 +1,38 @@
import { IGetSearchContextOutput } from 'hooks/useSearch';
import { FC, useState } from 'react'; import { FC, useState } from 'react';
import { IconButton, Tooltip } from '@mui/material';
import { Search } from '@mui/icons-material';
import { useAsyncDebounce } from 'react-table'; 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 { TableSearchField } from './TableSearchField/TableSearchField';
import { useStyles } from './TableSearch.styles';
interface ITableSearchProps { interface ITableSearchProps {
initialValue?: string; initialValue?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
placeholder?: string; placeholder?: string;
hasFilters?: boolean;
getSearchContext?: () => IGetSearchContextOutput;
} }
export const TableSearch: FC<ITableSearchProps> = ({ export const TableSearch: FC<ITableSearchProps> = ({
initialValue, initialValue,
onChange = () => {}, onChange = () => {},
placeholder = 'Search', placeholder = 'Search',
hasFilters,
getSearchContext,
}) => { }) => {
const [searchInputState, setSearchInputState] = useState(initialValue); const [searchInputState, setSearchInputState] = useState(initialValue);
const [isSearchExpanded, setIsSearchExpanded] = useState(
Boolean(initialValue)
);
const [isAnimating, setIsAnimating] = useState(false);
const debouncedOnSearch = useAsyncDebounce(onChange, 200); const debouncedOnSearch = useAsyncDebounce(onChange, 200);
const { classes: styles } = useStyles();
const onBlur = (clear = false) => {
if (!searchInputState || clear) {
setIsSearchExpanded(false);
}
};
const onSearchChange = (value: string) => { const onSearchChange = (value: string) => {
debouncedOnSearch(value); debouncedOnSearch(value);
setSearchInputState(value); setSearchInputState(value);
}; };
return ( return (
<> <TableSearchField
<AnimateOnMount value={searchInputState!}
mounted={isSearchExpanded} onChange={onSearchChange}
start={styles.searchField} placeholder={`${placeholder}`}
enter={styles.searchFieldEnter} hasFilters={hasFilters}
leave={styles.searchFieldLeave} getSearchContext={getSearchContext}
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>
}
/>
</>
); );
}; };

View File

@ -3,9 +3,14 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({ export const useStyles = makeStyles()(theme => ({
container: { container: {
display: 'flex', display: 'flex',
flexGrow: 1,
alignItems: 'center', alignItems: 'center',
flexWrap: 'wrap', position: 'relative',
gap: '1rem', maxWidth: '400px',
[theme.breakpoints.down('md')]: {
marginTop: theme.spacing(1),
maxWidth: '100%',
},
}, },
search: { search: {
display: 'flex', display: 'flex',
@ -14,10 +19,8 @@ export const useStyles = makeStyles()(theme => ({
border: `1px solid ${theme.palette.grey[300]}`, border: `1px solid ${theme.palette.grey[300]}`,
borderRadius: theme.shape.borderRadiusExtraLarge, borderRadius: theme.shape.borderRadiusExtraLarge,
padding: '3px 5px 3px 12px', padding: '3px 5px 3px 12px',
maxWidth: '450px', width: '100%',
[theme.breakpoints.down('sm')]: { zIndex: 3,
width: '100%',
},
'&.search-container:focus-within': { '&.search-container:focus-within': {
borderColor: theme.palette.primary.light, borderColor: theme.palette.primary.light,
boxShadow: theme.boxShadows.main, boxShadow: theme.boxShadows.main,

View File

@ -3,13 +3,17 @@ import { Search, Close } from '@mui/icons-material';
import classnames from 'classnames'; import classnames from 'classnames';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useStyles } from './TableSearchField.styles'; import { useStyles } from './TableSearchField.styles';
import { TableSearchFieldSuggestions } from './TableSearchFieldSuggestions/TableSearchFieldSuggestions';
import { useState } from 'react';
import { IGetSearchContextOutput } from 'hooks/useSearch';
interface ITableSearchFieldProps { interface ITableSearchFieldProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
className?: string; className?: string;
placeholder: string; placeholder: string;
onBlur?: (clear?: boolean) => void; hasFilters?: boolean;
getSearchContext?: () => IGetSearchContextOutput;
} }
export const TableSearchField = ({ export const TableSearchField = ({
@ -17,9 +21,11 @@ export const TableSearchField = ({
onChange, onChange,
className, className,
placeholder, placeholder,
onBlur, hasFilters,
getSearchContext,
}: ITableSearchFieldProps) => { }: ITableSearchFieldProps) => {
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const [showSuggestions, setShowSuggestions] = useState(false);
return ( return (
<div className={styles.container}> <div className={styles.container}>
@ -34,7 +40,6 @@ export const TableSearchField = ({
className={classnames(styles.searchIcon, 'search-icon')} className={classnames(styles.searchIcon, 'search-icon')}
/> />
<InputBase <InputBase
autoFocus
placeholder={placeholder} placeholder={placeholder}
classes={{ classes={{
root: classnames(styles.inputRoot, 'input-container'), root: classnames(styles.inputRoot, 'input-container'),
@ -42,7 +47,8 @@ export const TableSearchField = ({
inputProps={{ 'aria-label': placeholder }} inputProps={{ 'aria-label': placeholder }}
value={value} value={value}
onChange={e => onChange(e.target.value)} onChange={e => onChange(e.target.value)}
onBlur={() => onBlur?.()} onFocus={() => setShowSuggestions(true)}
onBlur={() => setShowSuggestions(false)}
/> />
<div <div
className={classnames( className={classnames(
@ -58,7 +64,6 @@ export const TableSearchField = ({
size="small" size="small"
onClick={() => { onClick={() => {
onChange(''); onChange('');
onBlur?.(true);
}} }}
> >
<Close className={styles.clearIcon} /> <Close className={styles.clearIcon} />
@ -68,6 +73,14 @@ export const TableSearchField = ({
/> />
</div> </div>
</div> </div>
<ConditionallyRender
condition={Boolean(hasFilters) && showSuggestions}
show={
<TableSearchFieldSuggestions
getSearchContext={getSearchContext!}
/>
}
/>
</div> </div>
); );
}; };

View File

@ -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>
))}
</>
}
/>
</>
);
};

View File

@ -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>
))}
</>
);
};

View File

@ -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>
);
};

View File

@ -1,13 +1,7 @@
import { useEffect, useMemo, useState, VFC } from 'react'; import { useEffect, useMemo, useState, VFC } from 'react';
import { Link, useMediaQuery, useTheme } from '@mui/material'; import { Link, useMediaQuery, useTheme } from '@mui/material';
import { Link as RouterLink, useSearchParams } from 'react-router-dom'; import { Link as RouterLink, useSearchParams } from 'react-router-dom';
import { import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
SortingRule,
useFlexLayout,
useGlobalFilter,
useSortBy,
useTable,
} from 'react-table';
import { import {
Table, Table,
SortableTableHeader, SortableTableHeader,
@ -34,6 +28,7 @@ import { FeatureSchema } from 'openapi';
import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton'; import { CreateFeatureButton } from '../CreateFeatureButton/CreateFeatureButton';
import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell'; import { FeatureStaleCell } from './FeatureStaleCell/FeatureStaleCell';
import { useStyles } from './styles'; import { useStyles } from './styles';
import { useSearch } from 'hooks/useSearch';
const featuresPlaceholder: FeatureSchema[] = Array(15).fill({ const featuresPlaceholder: FeatureSchema[] = Array(15).fill({
name: 'Name of the feature', name: 'Name of the feature',
@ -53,7 +48,6 @@ const columns = [
sortType: 'date', sortType: 'date',
align: 'center', align: 'center',
maxWidth: 85, maxWidth: 85,
disableGlobalFilter: true,
}, },
{ {
Header: 'Type', Header: 'Type',
@ -61,7 +55,6 @@ const columns = [
Cell: FeatureTypeCell, Cell: FeatureTypeCell,
align: 'center', align: 'center',
maxWidth: 85, maxWidth: 85,
disableGlobalFilter: true,
}, },
{ {
Header: 'Name', Header: 'Name',
@ -69,6 +62,7 @@ const columns = [
minWidth: 150, minWidth: 150,
Cell: FeatureNameCell, Cell: FeatureNameCell,
sortType: 'alphanumeric', sortType: 'alphanumeric',
searchable: true,
}, },
{ {
Header: 'Created', Header: 'Created',
@ -76,7 +70,6 @@ const columns = [
Cell: DateCell, Cell: DateCell,
sortType: 'date', sortType: 'date',
maxWidth: 150, maxWidth: 150,
disableGlobalFilter: true,
}, },
{ {
Header: 'Project ID', Header: 'Project ID',
@ -86,6 +79,8 @@ const columns = [
), ),
sortType: 'alphanumeric', sortType: 'alphanumeric',
maxWidth: 150, maxWidth: 150,
filterName: 'project',
searchable: true,
}, },
{ {
Header: 'State', Header: 'State',
@ -93,7 +88,8 @@ const columns = [
Cell: FeatureStaleCell, Cell: FeatureStaleCell,
sortType: 'boolean', sortType: 'boolean',
maxWidth: 120, maxWidth: 120,
disableGlobalFilter: true, filterName: 'state',
filterParsing: (value: any) => (value ? 'stale' : 'active'),
}, },
// Always hidden -- for search // Always hidden -- for search
{ {
@ -115,10 +111,22 @@ export const FeatureToggleListTable: VFC = () => {
defaultSort defaultSort
); );
const { features = [], loading } = useFeatures(); const { features = [], loading } = useFeatures();
const [searchValue, setSearchValue] = useState(
searchParams.get('search') || ''
);
const {
data: searchedData,
getSearchText,
getSearchContext,
} = useSearch(columns, searchValue, features);
const data = useMemo( const data = useMemo(
() => () =>
features?.length === 0 && loading ? featuresPlaceholder : features, searchedData?.length === 0 && loading
[features, loading] ? featuresPlaceholder
: searchedData,
[searchedData, loading]
); );
const [initialState] = useState(() => ({ const [initialState] = useState(() => ({
@ -131,7 +139,6 @@ export const FeatureToggleListTable: VFC = () => {
}, },
], ],
hiddenColumns: ['description'], hiddenColumns: ['description'],
globalFilter: searchParams.get('search') || '',
})); }));
const { const {
@ -140,23 +147,18 @@ export const FeatureToggleListTable: VFC = () => {
headerGroups, headerGroups,
rows, rows,
prepareRow, prepareRow,
state: { globalFilter, sortBy }, state: { sortBy },
setGlobalFilter,
setHiddenColumns, setHiddenColumns,
} = useTable( } = useTable(
{ {
// @ts-expect-error -- fix in react-table v8
columns, columns,
// @ts-expect-error -- fix in react-table v8
data, data,
initialState, initialState,
sortTypes, sortTypes,
autoResetGlobalFilter: false,
autoResetSortBy: false, autoResetSortBy: false,
disableSortRemove: true, disableSortRemove: true,
disableMultiSort: true, disableMultiSort: true,
}, },
useGlobalFilter,
useSortBy, useSortBy,
useFlexLayout useFlexLayout
); );
@ -178,15 +180,15 @@ export const FeatureToggleListTable: VFC = () => {
if (sortBy[0].desc) { if (sortBy[0].desc) {
tableState.order = 'desc'; tableState.order = 'desc';
} }
if (globalFilter) { if (searchValue) {
tableState.search = globalFilter; tableState.search = searchValue;
} }
setSearchParams(tableState, { setSearchParams(tableState, {
replace: true, replace: true,
}); });
setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false }); setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false });
}, [sortBy, globalFilter, setSearchParams, setStoredParams]); }, [sortBy, searchValue, setSearchParams, setStoredParams]);
const [firstRenderedIndex, lastRenderedIndex] = const [firstRenderedIndex, lastRenderedIndex] =
useVirtualizedRange(rowHeight); useVirtualizedRange(rowHeight);
@ -203,16 +205,25 @@ export const FeatureToggleListTable: VFC = () => {
})`} })`}
actions={ actions={
<> <>
<TableSearch <ConditionallyRender
initialValue={globalFilter} condition={!isSmallScreen}
onChange={setGlobalFilter} show={
<>
<TableSearch
initialValue={searchValue}
onChange={setSearchValue}
hasFilters
getSearchContext={getSearchContext}
/>
<PageHeader.Divider />
</>
}
/> />
<PageHeader.Divider />
<Link <Link
component={RouterLink} component={RouterLink}
to="/archive" to="/archive"
underline="always" underline="always"
sx={{ marginRight: 3 }} sx={{ marginRight: 2 }}
> >
View archive View archive
</Link> </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}> <Table {...getTableProps()} rowHeight={rowHeight}>
{/* @ts-expect-error -- fix in react-table v8 */}
<SortableTableHeader headerGroups={headerGroups} flex /> <SortableTableHeader headerGroups={headerGroups} flex />
<TableBody <TableBody
{...getTableBodyProps()} {...getTableBodyProps()}
@ -281,11 +303,11 @@ export const FeatureToggleListTable: VFC = () => {
condition={rows.length === 0} condition={rows.length === 0}
show={ show={
<ConditionallyRender <ConditionallyRender
condition={globalFilter?.length > 0} condition={searchValue?.length > 0}
show={ show={
<TablePlaceholder> <TablePlaceholder>
No feature toggles found matching &ldquo; No feature toggles found matching &ldquo;
{globalFilter} {searchValue}
&rdquo; &rdquo;
</TablePlaceholder> </TablePlaceholder>
} }

View File

@ -28,9 +28,6 @@ export const useStyles = makeStyles()(theme => ({
}, },
title: { title: {
display: 'unset', display: 'unset',
[theme.breakpoints.down(600)]: {
display: 'none',
},
}, },
iconButton: { iconButton: {
marginRight: '1rem', marginRight: '1rem',

View File

@ -2,13 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTheme } from '@mui/system'; import { useTheme } from '@mui/system';
import { Add } from '@mui/icons-material'; import { Add } from '@mui/icons-material';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { import { useFlexLayout, useSortBy, useTable, SortingRule } from 'react-table';
useGlobalFilter,
useFlexLayout,
useSortBy,
useTable,
SortingRule,
} from 'react-table';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { PageContent } from 'component/common/PageContent/PageContent'; import { PageContent } from 'component/common/PageContent/PageContent';
@ -48,6 +42,8 @@ import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu';
import { useStyles } from './ProjectFeatureToggles.styles'; import { useStyles } from './ProjectFeatureToggles.styles';
import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog'; import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog';
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog'; import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
import { useSearch } from 'hooks/useSearch';
import { useMediaQuery } from '@mui/material';
interface IProjectFeatureTogglesProps { interface IProjectFeatureTogglesProps {
features: IProject['features']; features: IProject['features'];
@ -82,6 +78,8 @@ export const ProjectFeatureToggles = ({
environments: newEnvironments = [], environments: newEnvironments = [],
}: IProjectFeatureTogglesProps) => { }: IProjectFeatureTogglesProps) => {
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const [strategiesDialogState, setStrategiesDialogState] = useState({ const [strategiesDialogState, setStrategiesDialogState] = useState({
open: false, open: false,
featureId: '', featureId: '',
@ -102,53 +100,11 @@ export const ProjectFeatureToggles = ({
); );
const { refetch } = useProject(projectId); const { refetch } = useProject(projectId);
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const theme = useTheme();
const rowHeight = theme.shape.tableRowHeight; 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 } = const { toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } =
useFeatureApi(); useFeatureApi();
const onToggle = useCallback( const onToggle = useCallback(
async ( async (
projectId: string, projectId: string,
@ -223,7 +179,7 @@ export const ProjectFeatureToggles = ({
), ),
minWidth: 100, minWidth: 100,
sortType: 'alphanumeric', sortType: 'alphanumeric',
disableGlobalFilter: false, searchable: true,
}, },
{ {
Header: 'Created', Header: 'Created',
@ -257,6 +213,9 @@ export const ProjectFeatureToggles = ({
const b = v2?.values?.[id]?.enabled; const b = v2?.values?.[id]?.enabled;
return a === b ? 0 : a ? -1 : 1; return a === b ? 0 : a ? -1 : 1;
}, },
filterName: name,
filterParsing: (value: any) =>
value.enabled ? 'enabled' : 'disabled',
})), })),
{ {
id: 'Actions', id: 'Actions',
@ -275,12 +234,70 @@ export const ProjectFeatureToggles = ({
], ],
[projectId, environments, onToggle, loading] [projectId, environments, onToggle, loading]
); );
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [storedParams, setStoredParams] = useLocalStorage( const [storedParams, setStoredParams] = useLocalStorage(
`${projectId}:ProjectFeatureToggles`, `${projectId}:ProjectFeatureToggles`,
defaultSort 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 initialState = useMemo(
() => { () => {
const allColumnIds = columns.map( const allColumnIds = columns.map(
@ -317,7 +334,6 @@ export const ProjectFeatureToggles = ({
}, },
], ],
hiddenColumns, hiddenColumns,
globalFilter: searchParams.get('search') || '',
}; };
}, },
[environments] // eslint-disable-line react-hooks/exhaustive-deps [environments] // eslint-disable-line react-hooks/exhaustive-deps
@ -327,11 +343,10 @@ export const ProjectFeatureToggles = ({
allColumns, allColumns,
headerGroups, headerGroups,
rows, rows,
state: { globalFilter, sortBy, hiddenColumns }, state: { sortBy, hiddenColumns },
getTableBodyProps, getTableBodyProps,
getTableProps, getTableProps,
prepareRow, prepareRow,
setGlobalFilter,
setHiddenColumns, setHiddenColumns,
} = useTable( } = useTable(
{ {
@ -339,15 +354,10 @@ export const ProjectFeatureToggles = ({
data, data,
initialState, initialState,
sortTypes, sortTypes,
autoResetGlobalFilter: false,
disableSortRemove: true, disableSortRemove: true,
autoResetSortBy: false, autoResetSortBy: false,
defaultColumn: {
disableGlobalFilter: true,
},
}, },
useFlexLayout, useFlexLayout,
useGlobalFilter,
useSortBy useSortBy
); );
@ -360,8 +370,8 @@ export const ProjectFeatureToggles = ({
if (sortBy[0].desc) { if (sortBy[0].desc) {
tableState.order = 'desc'; tableState.order = 'desc';
} }
if (globalFilter) { if (searchValue) {
tableState.search = globalFilter; tableState.search = searchValue;
} }
tableState.columns = allColumns tableState.columns = allColumns
.map(({ id }) => id) .map(({ id }) => id)
@ -380,7 +390,7 @@ export const ProjectFeatureToggles = ({
columns: tableState.columns.split(','), columns: tableState.columns.split(','),
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, sortBy, hiddenColumns, globalFilter, setSearchParams]); }, [loading, sortBy, hiddenColumns, searchValue, setSearchParams]);
const onCustomizeColumns = useCallback( const onCustomizeColumns = useCallback(
visibleColumns => { visibleColumns => {
@ -404,12 +414,21 @@ export const ProjectFeatureToggles = ({
header={ header={
<PageHeader <PageHeader
className={styles.title} className={styles.title}
title={`Project feature toggles (${rows.length})`} title={`Feature toggles (${rows.length})`}
actions={ actions={
<> <>
<TableSearch <ConditionallyRender
initialValue={globalFilter} condition={!isSmallScreen}
onChange={value => setGlobalFilter(value)} show={
<TableSearch
initialValue={searchValue}
onChange={value =>
setSearchValue(value)
}
hasFilters
getSearchContext={getSearchContext}
/>
}
/> />
<ColumnsMenu <ColumnsMenu
allColumns={allColumns} allColumns={allColumns}
@ -420,7 +439,7 @@ export const ProjectFeatureToggles = ({
onCustomize={onCustomizeColumns} onCustomize={onCustomizeColumns}
setHiddenColumns={setHiddenColumns} setHiddenColumns={setHiddenColumns}
/> />
<PageHeader.Divider /> <PageHeader.Divider sx={{ marginLeft: 0 }} />
<ResponsiveButton <ResponsiveButton
onClick={() => onClick={() =>
navigate( navigate(
@ -430,7 +449,7 @@ export const ProjectFeatureToggles = ({
) )
) )
} }
maxWidth="700px" maxWidth="960px"
Icon={Add} Icon={Add}
projectId={projectId} projectId={projectId}
permission={CREATE_FEATURE} permission={CREATE_FEATURE}
@ -440,10 +459,22 @@ export const ProjectFeatureToggles = ({
</ResponsiveButton> </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}> <Table {...getTableProps()} rowHeight={rowHeight}>
<SortableTableHeader <SortableTableHeader
// @ts-expect-error -- verify after `react-table` v8 // @ts-expect-error -- verify after `react-table` v8
@ -502,11 +533,11 @@ export const ProjectFeatureToggles = ({
condition={rows.length === 0} condition={rows.length === 0}
show={ show={
<ConditionallyRender <ConditionallyRender
condition={globalFilter?.length > 0} condition={searchValue?.length > 0}
show={ show={
<TablePlaceholder> <TablePlaceholder>
No feature toggles found matching &ldquo; No feature toggles found matching &ldquo;
{globalFilter} {searchValue}
&rdquo; &rdquo;
</TablePlaceholder> </TablePlaceholder>
} }

View File

@ -31,46 +31,46 @@ exports[`renders an empty list correctly 1`] = `
<div <div
className="tss-u5t8ea-headerActions" className="tss-u5t8ea-headerActions"
> >
<button <div
aria-label="Search" className="tss-119iiqp-container"
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"
> >
<svg <div
aria-hidden={true} className="tss-1mtd8gr-search search-container"
className="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-i4bv87-MuiSvgIcon-root"
data-testid="SearchIcon"
focusable="false"
viewBox="0 0 24 24"
> >
<path <svg
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" 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> </div>
<span </div>
className="MuiTouchRipple-root mui-8je8zh-MuiTouchRipple-root"
/>
</button>
<hr <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 <span
id="useId-0" id="useId-0"

View 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,
},
]);
});
});

View 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) ?? [];