diff --git a/frontend/src/component/common/PageHeader/PageHeader.styles.ts b/frontend/src/component/common/PageHeader/PageHeader.styles.ts index c7a01a8b1e..e13fba7db2 100644 --- a/frontend/src/component/common/PageHeader/PageHeader.styles.ts +++ b/frontend/src/component/common/PageHeader/PageHeader.styles.ts @@ -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', - }, })); diff --git a/frontend/src/component/common/PageHeader/PageHeader.tsx b/frontend/src/component/common/PageHeader/PageHeader.tsx index faa24d8f06..b9798bcee5 100644 --- a/frontend/src/component/common/PageHeader/PageHeader.tsx +++ b/frontend/src/component/common/PageHeader/PageHeader.tsx @@ -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 & { Divider: VFC } = ({ +const PageHeaderComponent: FC & { + Divider: typeof PageHeaderDivider; +} = ({ title, titleElement, actions, @@ -57,16 +77,8 @@ const PageHeaderComponent: FC & { Divider: VFC } = ({ ); }; -const PageHeaderDivider: VFC = () => { - const { classes: styles } = useStyles(); - - return ( - - ); +const PageHeaderDivider: VFC<{ sx?: SxProps }> = ({ sx }) => { + return ; }; PageHeaderComponent.Divider = PageHeaderDivider; diff --git a/frontend/src/component/common/Table/TableSearch/TableSearch.styles.ts b/frontend/src/component/common/Table/TableSearch/TableSearch.styles.ts deleted file mode 100644 index 41f96b7925..0000000000 --- a/frontend/src/component/common/Table/TableSearch/TableSearch.styles.ts +++ /dev/null @@ -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', - }, -})); diff --git a/frontend/src/component/common/Table/TableSearch/TableSearch.tsx b/frontend/src/component/common/Table/TableSearch/TableSearch.tsx index 75822eb15b..e7d9a041ce 100644 --- a/frontend/src/component/common/Table/TableSearch/TableSearch.tsx +++ b/frontend/src/component/common/Table/TableSearch/TableSearch.tsx @@ -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 = ({ 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 ( - <> - setIsAnimating(true)} - onEnd={() => setIsAnimating(false)} - > - - - - setIsSearchExpanded(true)} - size="large" - className={styles.searchButton} - > - - - - } - /> - + ); }; diff --git a/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchField.styles.ts b/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchField.styles.ts index 875fcd5431..59e56e1eaa 100644 --- a/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchField.styles.ts +++ b/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchField.styles.ts @@ -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, diff --git a/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchField.tsx b/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchField.tsx index 059e4eec2c..9581b25ec1 100644 --- a/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchField.tsx +++ b/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchField.tsx @@ -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 (
@@ -34,7 +40,6 @@ export const TableSearchField = ({ className={classnames(styles.searchIcon, 'search-icon')} /> onChange(e.target.value)} - onBlur={() => onBlur?.()} + onFocus={() => setShowSuggestions(true)} + onBlur={() => setShowSuggestions(false)} />
{ onChange(''); - onBlur?.(true); }} > @@ -68,6 +73,14 @@ export const TableSearchField = ({ />
+ + } + /> ); }; diff --git a/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchFieldSuggestions/SearchDescription/SearchDescription.tsx b/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchFieldSuggestions/SearchDescription/SearchDescription.tsx new file mode 100644 index 0000000000..66396db282 --- /dev/null +++ b/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchFieldSuggestions/SearchDescription/SearchDescription.tsx @@ -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 = ({ + 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 ( + <> + + Searching for: +

+ {searchText}{' '} + {searchableColumnsString + ? ` in ${searchableColumnsString}` + : ''} +

+ + } + /> + 0} + show={ + <> + Filtering by: + {searchFilters.map(filter => ( +

+ + {filter.values.join(',')} + {' '} + in {filter.header}. Options:{' '} + {filter.options.join(', ')} +

+ ))} + + } + /> + + ); +}; diff --git a/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchFieldSuggestions/SearchInstructions/SearchInstructions.tsx b/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchFieldSuggestions/SearchInstructions/SearchInstructions.tsx new file mode 100644 index 0000000000..bfaf3a557e --- /dev/null +++ b/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchFieldSuggestions/SearchInstructions/SearchInstructions.tsx @@ -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 = ({ + filters, + getSearchContext, + searchableColumnsString, +}) => { + return ( + <> + + {filters.length > 0 + ? 'Filter your search with operators like:' + : `Start typing to search${ + searchableColumnsString + ? ` in ${searchableColumnsString}` + : '...' + }`} + + {filters.map(filter => ( +

+ Filter by {filter.header}:{' '} + + {filter.name}:{filter.options[0]} + + 1} + show={ + <> + {' or '} + + {filter.name}: + {filter.options.slice(0, 2).join(',')} + + + } + /> +

+ ))} + + ); +}; diff --git a/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchFieldSuggestions/TableSearchFieldSuggestions.tsx b/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchFieldSuggestions/TableSearchFieldSuggestions.tsx new file mode 100644 index 0000000000..db67131ef4 --- /dev/null +++ b/frontend/src/component/common/Table/TableSearch/TableSearchField/TableSearchFieldSuggestions/TableSearchFieldSuggestions.tsx @@ -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 ( + + + + + + } + elseShow={ + + } + /> + + + + 0} + show="Combine filters and search." + /> +

+ Example:{' '} + + {filters.map(filter => ( + + {filter.name}:{filter.suggestedOption}{' '} + + ))} + {suggestedTextSearch} + +

+
+ ); +}; diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx index 12bdb65519..d3ca563b0a 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleListTable.tsx @@ -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={ <> - + + + + } /> - View archive @@ -222,12 +233,23 @@ export const FeatureToggleListTable: VFC = () => { /> } - /> + > + + } + /> + } > - + - {/* @ts-expect-error -- fix in react-table v8 */} { condition={rows.length === 0} show={ 0} + condition={searchValue?.length > 0} show={ No feature toggles found matching “ - {globalFilter} + {searchValue} ” } diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.styles.ts b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.styles.ts index e32f35f7fa..9d85368f65 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.styles.ts +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.styles.ts @@ -28,9 +28,6 @@ export const useStyles = makeStyles()(theme => ({ }, title: { display: 'unset', - [theme.breakpoints.down(600)]: { - display: 'none', - }, }, iconButton: { marginRight: '1rem', diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx index 37f0ee6d99..0d4a7af02e 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -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(() => { - 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(() => { + 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={ - setGlobalFilter(value)} + + setSearchValue(value) + } + hasFilters + getSearchContext={getSearchContext} + /> + } /> - + 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 = ({ } - /> + > + + } + /> + } > - +
0} + condition={searchValue?.length > 0} show={ No feature toggles found matching “ - {globalFilter} + {searchValue} ” } diff --git a/frontend/src/component/tags/TagTypeList/__tests__/__snapshots__/TagTypeList.test.tsx.snap b/frontend/src/component/tags/TagTypeList/__tests__/__snapshots__/TagTypeList.test.tsx.snap index 54a894ee13..1a967eb47b 100644 --- a/frontend/src/component/tags/TagTypeList/__tests__/__snapshots__/TagTypeList.test.tsx.snap +++ b/frontend/src/component/tags/TagTypeList/__tests__/__snapshots__/TagTypeList.test.tsx.snap @@ -31,46 +31,46 @@ exports[`renders an empty list correctly 1`] = `
- +
+
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, + }, + ]); + }); +}); diff --git a/frontend/src/hooks/useSearch.ts b/frontend/src/hooks/useSearch.ts new file mode 100644 index 0000000000..95fadd517a --- /dev/null +++ b/frontend/src/hooks/useSearch.ts @@ -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) ?? [];