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:
parent
ed2c2ec27c
commit
0e162362e6
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
@ -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();
|
||||||
|
});
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user