1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

Merge remote-tracking branch 'origin/main' into main

This commit is contained in:
andreas-unleash 2022-06-03 16:21:24 +03:00
commit 9edfc22ae1
19 changed files with 1100 additions and 381 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';
export const featuresPlaceholder: FeatureSchema[] = Array(15).fill({
name: 'Name of the feature',
@ -55,7 +50,6 @@ const columns = [
sortType: 'date',
align: 'center',
maxWidth: 85,
disableGlobalFilter: true,
},
{
Header: 'Type',
@ -63,7 +57,6 @@ const columns = [
Cell: FeatureTypeCell,
align: 'center',
maxWidth: 85,
disableGlobalFilter: true,
},
{
Header: 'Name',
@ -71,6 +64,7 @@ const columns = [
minWidth: 150,
Cell: FeatureNameCell,
sortType: 'alphanumeric',
searchable: true,
},
{
Header: 'Created',
@ -78,7 +72,6 @@ const columns = [
Cell: DateCell,
sortType: 'date',
maxWidth: 150,
disableGlobalFilter: true,
},
{
Header: 'Project ID',
@ -88,6 +81,8 @@ const columns = [
),
sortType: 'alphanumeric',
maxWidth: 150,
filterName: 'project',
searchable: true,
},
{
Header: 'State',
@ -95,7 +90,8 @@ const columns = [
Cell: FeatureStaleCell,
sortType: 'boolean',
maxWidth: 120,
disableGlobalFilter: true,
filterName: 'state',
filterParsing: (value: any) => (value ? 'stale' : 'active'),
},
// Always hidden -- for search
{
@ -117,10 +113,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(() => ({
@ -133,7 +141,6 @@ export const FeatureToggleListTable: VFC = () => {
},
],
hiddenColumns: ['description'],
globalFilter: searchParams.get('search') || '',
}));
const {
@ -142,23 +149,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
);
@ -180,15 +182,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);
@ -205,16 +207,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>
@ -224,12 +235,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()}
@ -283,11 +305,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

@ -1,4 +1,3 @@
import { Chip } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { IOverride, IPayload } from 'interfaces/featureToggle';

View File

@ -80,9 +80,6 @@ const Project = () => {
});
}
// @ts-expect-error
tabData.filter(tab => !tab.disabled);
/* eslint-disable-next-line */
}, []);

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

@ -1,58 +1,29 @@
/* eslint-disable react/jsx-no-target-blank */
import React, { useCallback, useState } from 'react';
import { Alert, SelectChangeEvent } from '@mui/material';
import { ProjectAccessAddUser } from './ProjectAccessAddUser/ProjectAccessAddUser';
import React, { useContext } from 'react';
import { ProjectAccessPage } from 'component/project/ProjectAccess/ProjectAccessPage';
import { PageContent } from 'component/common/PageContent/PageContent';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useStyles } from './ProjectAccess.styles';
import useToast from 'hooks/useToast';
import { Dialogue as ConfirmDialogue } from 'component/common/Dialogue/Dialogue';
import useProjectAccess, {
IProjectAccessUser,
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
import { Alert } from '@mui/material';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import AccessContext from 'contexts/AccessContext';
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { ProjectAccessTable } from './ProjectAccessTable/ProjectAccessTable';
export const ProjectAccess = () => {
const projectId = useRequiredPathParam('projectId');
const { classes: styles } = useStyles();
const { access, refetchProjectAccess } = useProjectAccess(projectId);
const { setToastData } = useToast();
const { hasAccess } = useContext(AccessContext);
const { isOss } = useUiConfig();
const { removeUserFromRole, changeUserRole } = useProjectApi();
const [showDelDialogue, setShowDelDialogue] = useState(false);
const [user, setUser] = useState<IProjectAccessUser | undefined>();
const handleRoleChange = useCallback(
(userId: number) => async (evt: SelectChangeEvent) => {
const roleId = Number(evt.target.value);
try {
await changeUserRole(projectId, roleId, userId);
refetchProjectAccess();
setToastData({
type: 'success',
title: 'Success',
text: 'User role changed successfully',
});
} catch (err: any) {
setToastData({
type: 'error',
title: err.message || 'Server problems when adding users.',
});
}
},
[changeUserRole, projectId, refetchProjectAccess, setToastData]
);
if (isOss()) {
return (
<PageContent header={<PageHeader title="Project Access" />}>
<PageContent header={<PageHeader title="Project access" />}>
<Alert severity="error">
Controlling access to projects requires a paid version of
Unleash. Check out{' '}
<a href="https://www.getunleash.io" target="_blank">
<a
href="https://www.getunleash.io"
target="_blank"
rel="noreferrer"
>
getunleash.io
</a>{' '}
to find out more.
@ -61,56 +32,15 @@ export const ProjectAccess = () => {
);
}
const handleRemoveAccess = (user: IProjectAccessUser) => {
setUser(user);
setShowDelDialogue(true);
};
if (!hasAccess(UPDATE_PROJECT, projectId)) {
return (
<PageContent header={<PageHeader title="Project access" />}>
<Alert severity="error">
You need project owner permissions to access this section.
</Alert>
</PageContent>
);
}
const removeAccess = (user: IProjectAccessUser | undefined) => async () => {
if (!user) return;
const { id, roleId } = user;
try {
await removeUserFromRole(projectId, roleId, id);
refetchProjectAccess();
setToastData({
type: 'success',
title: 'The user has been removed from project',
});
} catch (err: any) {
setToastData({
type: 'error',
title: err.message || 'Server problems when adding users.',
});
}
setShowDelDialogue(false);
};
return (
<PageContent
header={<PageHeader title="Project Roles"></PageHeader>}
className={styles.pageContent}
>
<ProjectAccessAddUser roles={access?.roles} />
<div className={styles.divider}></div>
<ProjectAccessTable
access={access}
handleRoleChange={handleRoleChange}
handleRemoveAccess={handleRemoveAccess}
projectId={projectId}
/>
<ConfirmDialogue
open={showDelDialogue}
onClick={removeAccess(user)}
onClose={() => {
setUser(undefined);
setShowDelDialogue(false);
}}
title="Really remove user from this project"
/>
</PageContent>
);
return <ProjectAccessPage />;
};

View File

@ -0,0 +1,95 @@
import React, { useCallback, useState } from 'react';
import { SelectChangeEvent } from '@mui/material';
import { ProjectAccessAddUser } from './ProjectAccessAddUser/ProjectAccessAddUser';
import { PageContent } from 'component/common/PageContent/PageContent';
import { useStyles } from './ProjectAccess.styles';
import useToast from 'hooks/useToast';
import { Dialogue as ConfirmDialogue } from 'component/common/Dialogue/Dialogue';
import useProjectAccess, {
IProjectAccessUser,
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { ProjectAccessTable } from './ProjectAccessTable/ProjectAccessTable';
export const ProjectAccessPage = () => {
const projectId = useRequiredPathParam('projectId');
const { classes: styles } = useStyles();
const { access, refetchProjectAccess } = useProjectAccess(projectId);
const { setToastData } = useToast();
const { removeUserFromRole, changeUserRole } = useProjectApi();
const [showDelDialogue, setShowDelDialogue] = useState(false);
const [user, setUser] = useState<IProjectAccessUser | undefined>();
const handleRoleChange = useCallback(
(userId: number) => async (evt: SelectChangeEvent) => {
const roleId = Number(evt.target.value);
try {
await changeUserRole(projectId, roleId, userId);
refetchProjectAccess();
setToastData({
type: 'success',
title: 'Success',
text: 'User role changed successfully',
});
} catch (err: any) {
setToastData({
type: 'error',
title: err.message || 'Server problems when adding users.',
});
}
},
[changeUserRole, projectId, refetchProjectAccess, setToastData]
);
const handleRemoveAccess = (user: IProjectAccessUser) => {
setUser(user);
setShowDelDialogue(true);
};
const removeAccess = (user: IProjectAccessUser | undefined) => async () => {
if (!user) return;
const { id, roleId } = user;
try {
await removeUserFromRole(projectId, roleId, id);
refetchProjectAccess();
setToastData({
type: 'success',
title: 'The user has been removed from project',
});
} catch (err: any) {
setToastData({
type: 'error',
title: err.message || 'Server problems when adding users.',
});
}
setShowDelDialogue(false);
};
return (
<PageContent
header={<PageHeader title="Project roles" />}
className={styles.pageContent}
>
<ProjectAccessAddUser roles={access?.roles} />
<div className={styles.divider} />
<ProjectAccessTable
access={access}
handleRoleChange={handleRoleChange}
handleRemoveAccess={handleRemoveAccess}
projectId={projectId}
/>
<ConfirmDialogue
open={showDelDialogue}
onClick={removeAccess(user)}
onClose={() => {
setUser(undefined);
setShowDelDialogue(false);
}}
title="Really remove user from this project"
/>
</PageContent>
);
};

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