diff --git a/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx b/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx index df9653db26..20d3a6815b 100644 --- a/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx +++ b/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx @@ -72,9 +72,11 @@ export const SearchSuggestions: VFC = ({ return { name: column.filterName, header: column.Header ?? column.filterName, - options: [...new Set(filterOptions)].sort((a, b) => - a.localeCompare(b) - ), + options: [...new Set(filterOptions)] + .filter(Boolean) + .flatMap(item => item.split('\n')) + .map(item => (item.includes(' ') ? `"${item}"` : item)) + .sort((a, b) => a.localeCompare(b)), suggestedOption: filterOptions[randomRow] ?? `example-${column.filterName}`, values: getFilterValues( diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx index 4576b275ee..0d1ce1261b 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -36,7 +36,7 @@ import { createLocalStorage } from 'utils/createLocalStorage'; import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog'; import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog'; import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog'; -import { useSearch } from 'hooks/useSearch'; +import { getColumnValues, includesFilter, useSearch } from 'hooks/useSearch'; import { Search } from 'component/common/Search/Search'; import { useChangeRequestToggle } from 'hooks/useChangeRequestToggle'; import { ChangeRequestDialogue } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog'; @@ -234,6 +234,7 @@ export const ProjectFeatureToggles = ({ accessor: 'type', Cell: FeatureTypeCell, align: 'center', + filterName: 'type', maxWidth: 80, }, { @@ -265,6 +266,16 @@ export const ProjectFeatureToggles = ({ Cell: FeatureTagCell, width: 80, searchable: true, + filterName: 'tags', + filterBy( + row: IFeatureToggleListItem, + values: string[] + ) { + return includesFilter( + getColumnValues(this, row), + values + ); + }, }, ] : []), diff --git a/frontend/src/hooks/useSearch.test.ts b/frontend/src/hooks/useSearch.test.tsx similarity index 61% rename from frontend/src/hooks/useSearch.test.ts rename to frontend/src/hooks/useSearch.test.tsx index 493654cfe6..be45fb8aca 100644 --- a/frontend/src/hooks/useSearch.test.ts +++ b/frontend/src/hooks/useSearch.test.tsx @@ -3,7 +3,13 @@ import { getSearchTextGenerator, searchInFilteredData, filter, + useSearch, + includesFilter, + getColumnValues, } from './useSearch'; +import { FC } from 'react'; +import { render, screen } from '@testing-library/react'; +import { IFeatureToggleListItem } from '../interfaces/featureToggle'; const columns = [ { @@ -32,6 +38,16 @@ const columns = [ searchBy: (row: any, value: string) => (value === 'seen' && row.seen) || (value === 'never' && !row.seen), }, + { + accessor: (row: IFeatureToggleListItem) => + row.tags?.map(({ type, value }) => `${type}:${value}`).join('\n') || + '', + searchable: true, + filterName: 'tags', + filterBy(row: IFeatureToggleListItem, values: string[]) { + return includesFilter(getColumnValues(this, row), values); + }, + }, ]; const data = [ @@ -41,6 +57,10 @@ const data = [ stale: false, type: 'release', seen: true, + tags: [ + { type: 'simple', value: 'tag' }, + { type: 'simple', value: 'some space' }, + ], }, { name: 'my-feature-toggle-2', @@ -48,6 +68,7 @@ const data = [ stale: true, type: 'experiment', seen: false, + tags: [], }, { name: 'my-feature-toggle-3', @@ -55,6 +76,7 @@ const data = [ stale: false, type: 'operational', seen: false, + tags: [], }, { name: 'my-feature-toggle-4', @@ -62,6 +84,7 @@ const data = [ stale: true, type: 'permission', seen: true, + tags: [], }, ]; @@ -143,6 +166,7 @@ describe('searchInFilteredData', () => { name: 'my-feature-toggle-3', project: 'my-project', stale: false, + tags: [], type: 'operational', seen: false, }, @@ -150,6 +174,7 @@ describe('searchInFilteredData', () => { name: 'my-feature-toggle-4', project: 'my-project', stale: true, + tags: [], type: 'permission', seen: true, }, @@ -162,6 +187,7 @@ describe('searchInFilteredData', () => { name: 'my-feature-toggle-2', project: 'default', stale: true, + tags: [], type: 'experiment', seen: false, }, @@ -187,6 +213,7 @@ describe('searchInFilteredData', () => { name: 'my-feature-toggle-2', project: 'default', stale: true, + tags: [], type: 'experiment', seen: false, }, @@ -201,6 +228,7 @@ describe('searchInFilteredData', () => { name: 'my-feature-toggle-2', project: 'default', stale: true, + tags: [], type: 'experiment', seen: false, }, @@ -208,6 +236,7 @@ describe('searchInFilteredData', () => { name: 'my-feature-toggle-3', project: 'my-project', stale: false, + tags: [], type: 'operational', seen: false, }, @@ -225,6 +254,10 @@ describe('filter', () => { name: 'my-feature-toggle', project: 'default', stale: false, + tags: [ + { type: 'simple', value: 'tag' }, + { type: 'simple', value: 'some space' }, + ], type: 'release', seen: true, }, @@ -232,6 +265,7 @@ describe('filter', () => { name: 'my-feature-toggle-2', project: 'default', stale: true, + tags: [], type: 'experiment', seen: false, }, @@ -244,6 +278,10 @@ describe('filter', () => { name: 'my-feature-toggle', project: 'default', stale: false, + tags: [ + { type: 'simple', value: 'tag' }, + { type: 'simple', value: 'some space' }, + ], type: 'release', seen: true, }, @@ -251,6 +289,7 @@ describe('filter', () => { name: 'my-feature-toggle-3', project: 'my-project', stale: false, + tags: [], type: 'operational', seen: false, }, @@ -276,6 +315,7 @@ describe('filter', () => { name: 'my-feature-toggle-3', project: 'my-project', stale: false, + tags: [], type: 'operational', seen: false, }, @@ -283,6 +323,7 @@ describe('filter', () => { name: 'my-feature-toggle-4', project: 'my-project', stale: true, + tags: [], type: 'permission', seen: true, }, @@ -297,6 +338,7 @@ describe('filter', () => { name: 'my-feature-toggle-2', project: 'default', stale: true, + tags: [], type: 'experiment', seen: false, }, @@ -304,9 +346,154 @@ describe('filter', () => { name: 'my-feature-toggle-4', project: 'my-project', stale: true, + tags: [], type: 'permission', seen: true, }, ]); }); }); + +const SearchData: FC<{ searchValue: string }> = ({ searchValue }) => { + const search = useSearch(columns, searchValue, data); + + return
{search.data.map(item => item.name).join(',')}
; +}; + +const SearchText: FC<{ searchValue: string }> = ({ searchValue }) => { + const search = useSearch(columns, searchValue, data); + + return
{search.getSearchText(searchValue)}
; +}; + +describe('Search and filter data', () => { + it('should filter single value', () => { + render(); + + screen.getByText('my-feature-toggle-3,my-feature-toggle-4'); + }); + + it('should filter multiple values', () => { + render(); + + screen.getByText('my-feature-toggle-3,my-feature-toggle-4'); + }); + + it('should filter multiple values with spaces', () => { + render( + + ); + + screen.getByText('my-feature-toggle-3,my-feature-toggle-4'); + }); + + it('should handle multiple filters', () => { + render( + + ); + + screen.getByText('my-feature-toggle-3'); + }); + + it('should handle multiple filters with long spaces', () => { + render( + + ); + + screen.getByText('my-feature-toggle-3,my-feature-toggle-4'); + }); + + it('should handle multiple filters and search string in between', () => { + render( + + ); + + screen.getByText('my-feature-toggle-3'); + }); + + it('should handle multiple filters and search string at the end', () => { + render( + + ); + + screen.getByText('my-feature-toggle-3'); + }); + + it('should handle multiple filters and search string at the beginning', () => { + render( + + ); + + screen.getByText('my-feature-toggle-3'); + }); + + it('should return basic search text', () => { + render(); + + screen.getByText('toggle-3'); + }); + + it('should return advanced search text', () => { + render( + + ); + + screen.getByText('toggle-3'); + }); + + it('should support custom filter and accessor', () => { + render(); + + screen.getByText('my-feature-toggle'); + }); + + it('should support search on top of filter', () => { + render(); + + screen.getByText('simple:tag'); + }); + + it('should support custom filter with spaces', () => { + render(); + + screen.getByText('my-feature-toggle'); + }); + + it('should support custom filter with spaces - space in second term', () => { + render(); + + screen.getByText('my-feature-toggle'); + }); + + it('should support quotes in filter and search', () => { + render( + + ); + + screen.getByText('my-feature-toggle'); + }); +}); diff --git a/frontend/src/hooks/useSearch.ts b/frontend/src/hooks/useSearch.ts index f15bf2fb64..6e9c3673f2 100644 --- a/frontend/src/hooks/useSearch.ts +++ b/frontend/src/hooks/useSearch.ts @@ -12,32 +12,45 @@ type IUseSearchOutput = { getSearchContext: () => IGetSearchContextOutput; }; +// https://stackoverflow.com/questions/9577930/regular-expression-to-select-all-whitespace-that-isnt-in-quotes +const SPACES_WITHOUT_QUOTES = /\s+(?=(?:[^\'"]*[\'"][^\'"]*[\'"])*[^\'"]*$)/g; + +const normalizeSearchValue = (value: string) => + value.replaceAll(/\s*,\s*/g, ','); + +const removeQuotes = (value: string) => + value.replaceAll("'", '').replaceAll('"', ''); + export const useSearch = ( columns: any[], searchValue: string, data: T[] ): IUseSearchOutput => { const getSearchText = useCallback( - (value: string) => getSearchTextGenerator(columns)(value), + (value: string) => + removeQuotes( + getSearchTextGenerator(columns)(normalizeSearchValue(value)) + ), [columns] ); + const normalizedSearchValue = normalizeSearchValue(searchValue); const getSearchContext = useCallback(() => { - return { data, searchValue, columns }; - }, [data, searchValue, columns]); + return { data, searchValue: normalizedSearchValue, columns }; + }, [data, normalizedSearchValue, columns]); const search = useMemo(() => { - if (!searchValue) return data; + if (!normalizedSearchValue) return data; - const filteredData = filter(columns, searchValue, data); + const filteredData = filter(columns, normalizedSearchValue, data); const searchedData = searchInFilteredData( columns, - getSearchText(searchValue), + getSearchText(normalizedSearchValue), filteredData ); return searchedData; - }, [columns, searchValue, data, getSearchText]); + }, [columns, normalizedSearchValue, data, getSearchText]); return { data: search, getSearchText, getSearchContext }; }; @@ -67,6 +80,7 @@ export const searchInFilteredData = ( searchValue: string, filteredData: T[] ) => { + const trimmedSearchValue = searchValue.trim(); const searchableColumns = columns.filter( column => column.searchable && column.accessor ); @@ -74,10 +88,13 @@ export const searchInFilteredData = ( return filteredData.filter(row => { return searchableColumns.some(column => { if (column.searchBy) { - return column.searchBy(row, searchValue); + return column.searchBy(row, trimmedSearchValue); } - return defaultSearch(getColumnValues(column, row), searchValue); + return defaultSearch( + getColumnValues(column, row), + trimmedSearchValue + ); }); }); }; @@ -85,6 +102,11 @@ export const searchInFilteredData = ( const defaultFilter = (fieldValue: string, values: string[]) => values.some(value => fieldValue?.toLowerCase() === value?.toLowerCase()); +export const includesFilter = (fieldValue: string, values: string[]) => + values.some(value => + fieldValue?.toLowerCase().includes(value?.toLowerCase()) + ); + const defaultSearch = (fieldValue: string, value: string) => fieldValue?.toLowerCase().includes(value?.toLowerCase()); @@ -99,13 +121,14 @@ export const getSearchTextGenerator = (columns: any[]) => { return (searchValue: string) => searchValue - .split(' ') + .split(SPACES_WITHOUT_QUOTES) .filter(fragment => !isValidSearch(fragment)) .join(' '); }; export const isValidFilter = (input: string, match: string) => - new RegExp(`${match}:\\w+`).test(input); + // name:"hello world" or name:'hello world' or name:simple + new RegExp(`${match}:(?:\\w+|["'][^"']+["'])`).test(input); export const getFilterableColumns = (columns: any[]) => columns.filter(column => column.filterName && column.accessor); @@ -130,6 +153,7 @@ export const getColumnValues = (column: any, row: any) => { export const getFilterValues = (filterName: string, searchValue: string) => searchValue ?.split(`${filterName}:`)[1] - ?.split(' ')[0] + ?.split(SPACES_WITHOUT_QUOTES)[0] ?.split(',') + .map(removeQuotes) .filter(value => value) ?? [];