1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-01 01:18:10 +02:00

feat: Change request advanced search and filter (#4544)

This commit is contained in:
Mateusz Kwasniewski 2023-08-23 09:38:10 +02:00 committed by GitHub
parent ed2c2ec27c
commit 0e162362e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 151 additions and 21 deletions

View File

@ -1,5 +1,7 @@
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { styled, Typography } from '@mui/material'; import { styled, Typography } from '@mui/material';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
const StyledContainer = styled('div')(({ theme }) => ({ const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -9,12 +11,15 @@ const StyledContainer = styled('div')(({ theme }) => ({
})); }));
export const AvatarCell = ({ value }: any) => { export const AvatarCell = ({ value }: any) => {
const { searchQuery } = useSearchHighlightContext();
return ( return (
<TextCell> <TextCell>
<StyledContainer> <StyledContainer>
<Typography component={'span'} variant={'body2'}> <Typography component={'span'} variant={'body2'}>
{' '} {' '}
{value?.username} <Highlighter search={searchQuery}>
{value?.username}
</Highlighter>
</Typography> </Typography>
</StyledContainer> </StyledContainer>
</TextCell> </TextCell>

View File

@ -2,6 +2,8 @@ import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { Link, styled, Typography } from '@mui/material'; import { Link, styled, Typography } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
interface IChangeRequestTitleCellProps { interface IChangeRequestTitleCellProps {
value?: any; value?: any;
@ -18,6 +20,7 @@ export const ChangeRequestTitleCell = ({
value, value,
row: { original }, row: { original },
}: IChangeRequestTitleCellProps) => { }: IChangeRequestTitleCellProps) => {
const { searchQuery } = useSearchHighlightContext();
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const { const {
id, id,
@ -49,7 +52,7 @@ export const ChangeRequestTitleCell = ({
}, },
})} })}
> >
{title} <Highlighter search={searchQuery}>{title}</Highlighter>
</Link> </Link>
</Typography> </Typography>
</StyledLink> </StyledLink>

View File

@ -3,13 +3,15 @@ import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { import {
SortableTableHeader, SortableTableHeader,
Table, Table,
TableBody,
TableCell, TableCell,
TablePlaceholder, TablePlaceholder,
TableRow,
} from 'component/common/Table'; } from 'component/common/Table';
import { SortingRule, useSortBy, useTable } from 'react-table'; import { SortingRule, useSortBy, useTable } from 'react-table';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { styled, Tab, Tabs, Box, useMediaQuery } from '@mui/material'; import { Box, styled, Tab, Tabs, useMediaQuery } from '@mui/material';
import { Link } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { sortTypes } from 'utils/sortTypes'; import { sortTypes } from 'utils/sortTypes';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
@ -17,17 +19,16 @@ import { Search } from 'component/common/Search/Search';
import { featuresPlaceholder } from 'component/feature/FeatureToggleList/FeatureToggleListTable'; import { featuresPlaceholder } from 'component/feature/FeatureToggleList/FeatureToggleListTable';
import theme from 'themes/theme'; import theme from 'themes/theme';
import { useSearch } from 'hooks/useSearch'; import { useSearch } from 'hooks/useSearch';
import { useSearchParams } from 'react-router-dom';
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { ChangeRequestStatusCell } from './ChangeRequestStatusCell'; import { ChangeRequestStatusCell } from './ChangeRequestStatusCell';
import { AvatarCell } from './AvatarCell'; import { AvatarCell } from './AvatarCell';
import { ChangeRequestTitleCell } from './ChangeRequestTitleCell'; import { ChangeRequestTitleCell } from './ChangeRequestTitleCell';
import { TableBody, TableRow } from 'component/common/Table';
import { createLocalStorage } from 'utils/createLocalStorage'; import { createLocalStorage } from 'utils/createLocalStorage';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
import { useStyles } from './ChangeRequestsTabs.styles'; import { useStyles } from './ChangeRequestsTabs.styles';
import { FeaturesCell } from './FeaturesCell'; import { FeaturesCell } from './FeaturesCell';
import { HighlightCell } from '../../../common/Table/cells/HighlightCell/HighlightCell';
export interface IChangeRequestTableProps { export interface IChangeRequestTableProps {
changeRequests: any[]; changeRequests: any[];
@ -121,6 +122,21 @@ export const ChangeRequestsTabs = ({
Header: 'Updated feature toggles', Header: 'Updated feature toggles',
canSort: false, canSort: false,
accessor: 'features', accessor: 'features',
searchable: true,
filterName: 'feature',
filterParsing: (values: Array<{ name: string }>) => {
return values?.map(({ name }) => name).join('\n') || '';
},
filterBy: (
row: { features: Array<{ name: string }> },
values: Array<string>
) => {
return row.features.find(feature =>
values
.map(value => value.toLowerCase())
.includes(feature.name.toLowerCase())
);
},
Cell: ({ Cell: ({
value, value,
row: { row: {
@ -141,11 +157,14 @@ export const ChangeRequestsTabs = ({
canSort: false, canSort: false,
Cell: AvatarCell, Cell: AvatarCell,
align: 'left', align: 'left',
searchable: true,
filterName: 'by',
filterParsing: (value: { username?: string }) =>
value?.username || '',
}, },
{ {
Header: 'Submitted', Header: 'Submitted',
accessor: 'createdAt', accessor: 'createdAt',
searchable: true,
maxWidth: 100, maxWidth: 100,
Cell: TimeAgoCell, Cell: TimeAgoCell,
sortType: 'alphanumeric', sortType: 'alphanumeric',
@ -155,7 +174,8 @@ export const ChangeRequestsTabs = ({
accessor: 'environment', accessor: 'environment',
searchable: true, searchable: true,
maxWidth: 100, maxWidth: 100,
Cell: TextCell, Cell: HighlightCell,
filterName: 'environment',
}, },
{ {
Header: 'Status', Header: 'Status',
@ -163,6 +183,7 @@ export const ChangeRequestsTabs = ({
searchable: true, searchable: true,
maxWidth: '170px', maxWidth: '170px',
Cell: ChangeRequestStatusCell, Cell: ChangeRequestStatusCell,
filterName: 'status',
}, },
], ],
//eslint-disable-next-line //eslint-disable-next-line

View File

@ -1,8 +1,10 @@
import { Box, styled } from '@mui/material'; import { Box, styled } from '@mui/material';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { VFC } from 'react'; import { VFC } from 'react';
import { ConditionallyRender } from '../../../common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { TooltipLink } from '../../../common/TooltipLink/TooltipLink'; import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
const StyledBox = styled(Box)(({ theme }) => ({ const StyledBox = styled(Box)(({ theme }) => ({
display: 'flex', display: 'flex',
@ -42,6 +44,7 @@ interface FeaturesCellProps {
} }
export const FeaturesCell: VFC<FeaturesCellProps> = ({ value, project }) => { export const FeaturesCell: VFC<FeaturesCellProps> = ({ value, project }) => {
const { searchQuery } = useSearchHighlightContext();
const featureNames = value?.map((feature: any) => feature.name); const featureNames = value?.map((feature: any) => feature.name);
return ( return (
<StyledBox> <StyledBox>
@ -52,7 +55,9 @@ export const FeaturesCell: VFC<FeaturesCellProps> = ({ value, project }) => {
title={featureName} title={featureName}
to={`/projects/${project}/features/${featureName}`} to={`/projects/${project}/features/${featureName}`}
> >
{featureName} <Highlighter search={searchQuery}>
{featureName}
</Highlighter>
</StyledLink> </StyledLink>
))} ))}
elseShow={ elseShow={
@ -65,7 +70,9 @@ export const FeaturesCell: VFC<FeaturesCellProps> = ({ value, project }) => {
title={featureName} title={featureName}
to={`/projects/${project}/features/${featureName}`} to={`/projects/${project}/features/${featureName}`}
> >
{featureName} <Highlighter search={searchQuery}>
{featureName}
</Highlighter>
</StyledTooltipLink> </StyledTooltipLink>
))} ))}
</StyledTooltipContainer> </StyledTooltipContainer>

View File

@ -61,7 +61,7 @@ export const SearchDescription: VFC<ISearchDescriptionProps> = ({
{filter.values.join(',')} {filter.values.join(',')}
</StyledCode>{' '} </StyledCode>{' '}
in {filter.header}. Options:{' '} in {filter.header}. Options:{' '}
{filter.options.join(', ')} {filter.options.slice(0, 10).join(', ')}
</p> </p>
))} ))}
</> </>

View File

@ -0,0 +1,89 @@
import { screen } from '@testing-library/react';
import { render } from 'utils/testRenderer';
import { SearchSuggestions } from './SearchSuggestions';
const searchContext = {
data: [
{
title: 'Title A',
environment: 'prod',
},
{
title: 'Title B',
environment: 'dev env',
},
{
title: 'Title C',
environment: 'stage\npre-prod',
},
],
searchValue: '',
columns: [
{
Header: 'Title',
searchable: true,
accessor: 'title',
},
{
Header: 'Environment',
accessor: 'environment',
searchable: false,
filterName: 'environment',
},
],
};
test('displays search and filter instructions when no search value is provided', () => {
render(<SearchSuggestions getSearchContext={() => searchContext} />);
expect(
screen.getByText(/Filter your search with operators like:/i)
).toBeInTheDocument();
expect(screen.getByText(/Filter by Environment:/i)).toBeInTheDocument();
expect(
screen.getByText(/environment:"dev env",pre-prod/i)
).toBeInTheDocument();
expect(
screen.getByText(/Combine filters and search./i)
).toBeInTheDocument();
});
test('displays search and filter instructions when search value is provided', () => {
render(
<SearchSuggestions
getSearchContext={() => ({
...searchContext,
searchValue: 'Title',
})}
/>
);
expect(screen.getByText(/Searching for:/i)).toBeInTheDocument();
expect(screen.getByText(/in Title/i)).toBeInTheDocument();
expect(
screen.getByText(/Combine filters and search./i)
).toBeInTheDocument();
});
test('displays search and filter instructions when filter value is provided', () => {
render(
<SearchSuggestions
getSearchContext={() => ({
...searchContext,
searchValue: 'environment:prod',
})}
/>
);
expect(screen.getByText(/Filtering by:/i)).toBeInTheDocument();
expect(screen.getByText(/in Environment/i)).toBeInTheDocument();
expect(
screen.getByText(/Options: "dev env", pre-prod, prod, stage/i)
).toBeInTheDocument();
expect(
screen.getByText(/Combine filters and search./i)
).toBeInTheDocument();
});

View File

@ -11,8 +11,6 @@ import { useMemo, VFC } from 'react';
import { SearchDescription } from './SearchDescription/SearchDescription'; import { SearchDescription } from './SearchDescription/SearchDescription';
import { SearchInstructions } from './SearchInstructions/SearchInstructions'; import { SearchInstructions } from './SearchInstructions/SearchInstructions';
const randomIndex = (arr: any[]) => Math.floor(Math.random() * arr.length);
const StyledPaper = styled(Paper)(({ theme }) => ({ const StyledPaper = styled(Paper)(({ theme }) => ({
position: 'absolute', position: 'absolute',
width: '100%', width: '100%',
@ -53,6 +51,10 @@ interface SearchSuggestionsProps {
getSearchContext: () => IGetSearchContextOutput; getSearchContext: () => IGetSearchContextOutput;
} }
const quote = (item: string) => (item.includes(' ') ? `"${item}"` : item);
const randomIndex = (arr: any[]) => Math.floor(Math.random() * arr.length);
export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({ export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({
getSearchContext, getSearchContext,
}) => { }) => {
@ -69,16 +71,19 @@ export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({
getColumnValues(column, row) getColumnValues(column, row)
); );
const options = [...new Set(filterOptions)]
.filter(Boolean)
.flatMap(item => item.split('\n'))
.filter(item => !item.includes('"') && !item.includes("'"))
.map(quote)
.sort((a, b) => a.localeCompare(b));
return { return {
name: column.filterName, name: column.filterName,
header: column.Header ?? column.filterName, header: column.Header ?? column.filterName,
options: [...new Set(filterOptions)] options,
.filter(Boolean)
.flatMap(item => item.split('\n'))
.map(item => (item.includes(' ') ? `"${item}"` : item))
.sort((a, b) => a.localeCompare(b)),
suggestedOption: suggestedOption:
filterOptions[randomRow] ?? `example-${column.filterName}`, options[randomRow] ?? `example-${column.filterName}`,
values: getFilterValues( values: getFilterValues(
column.filterName, column.filterName,
searchContext.searchValue searchContext.searchValue