1
0
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:
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',
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 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;

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

View File

@ -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,

View File

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

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 { 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 &ldquo;
{globalFilter}
{searchValue}
&rdquo;
</TablePlaceholder>
}

View File

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

View File

@ -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 &ldquo;
{globalFilter}
{searchValue}
&rdquo;
</TablePlaceholder>
}

View File

@ -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"

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