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

feat: more powerful project search (#4542)

This commit is contained in:
Mateusz Kwasniewski 2023-08-22 11:32:25 +02:00 committed by GitHub
parent 3acff3e6d9
commit 8a3889d570
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 240 additions and 16 deletions

View File

@ -72,9 +72,11 @@ export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({
return { return {
name: column.filterName, name: column.filterName,
header: column.Header ?? column.filterName, header: column.Header ?? column.filterName,
options: [...new Set(filterOptions)].sort((a, b) => options: [...new Set(filterOptions)]
a.localeCompare(b) .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}`, filterOptions[randomRow] ?? `example-${column.filterName}`,
values: getFilterValues( values: getFilterValues(

View File

@ -36,7 +36,7 @@ import { createLocalStorage } from 'utils/createLocalStorage';
import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog'; import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog';
import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog'; import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog';
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog'; 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 { Search } from 'component/common/Search/Search';
import { useChangeRequestToggle } from 'hooks/useChangeRequestToggle'; import { useChangeRequestToggle } from 'hooks/useChangeRequestToggle';
import { ChangeRequestDialogue } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog'; import { ChangeRequestDialogue } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog';
@ -234,6 +234,7 @@ export const ProjectFeatureToggles = ({
accessor: 'type', accessor: 'type',
Cell: FeatureTypeCell, Cell: FeatureTypeCell,
align: 'center', align: 'center',
filterName: 'type',
maxWidth: 80, maxWidth: 80,
}, },
{ {
@ -265,6 +266,16 @@ export const ProjectFeatureToggles = ({
Cell: FeatureTagCell, Cell: FeatureTagCell,
width: 80, width: 80,
searchable: true, searchable: true,
filterName: 'tags',
filterBy(
row: IFeatureToggleListItem,
values: string[]
) {
return includesFilter(
getColumnValues(this, row),
values
);
},
}, },
] ]
: []), : []),

View File

@ -3,7 +3,13 @@ import {
getSearchTextGenerator, getSearchTextGenerator,
searchInFilteredData, searchInFilteredData,
filter, filter,
useSearch,
includesFilter,
getColumnValues,
} from './useSearch'; } from './useSearch';
import { FC } from 'react';
import { render, screen } from '@testing-library/react';
import { IFeatureToggleListItem } from '../interfaces/featureToggle';
const columns = [ const columns = [
{ {
@ -32,6 +38,16 @@ const columns = [
searchBy: (row: any, value: string) => searchBy: (row: any, value: string) =>
(value === 'seen' && row.seen) || (value === 'never' && !row.seen), (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 = [ const data = [
@ -41,6 +57,10 @@ const data = [
stale: false, stale: false,
type: 'release', type: 'release',
seen: true, seen: true,
tags: [
{ type: 'simple', value: 'tag' },
{ type: 'simple', value: 'some space' },
],
}, },
{ {
name: 'my-feature-toggle-2', name: 'my-feature-toggle-2',
@ -48,6 +68,7 @@ const data = [
stale: true, stale: true,
type: 'experiment', type: 'experiment',
seen: false, seen: false,
tags: [],
}, },
{ {
name: 'my-feature-toggle-3', name: 'my-feature-toggle-3',
@ -55,6 +76,7 @@ const data = [
stale: false, stale: false,
type: 'operational', type: 'operational',
seen: false, seen: false,
tags: [],
}, },
{ {
name: 'my-feature-toggle-4', name: 'my-feature-toggle-4',
@ -62,6 +84,7 @@ const data = [
stale: true, stale: true,
type: 'permission', type: 'permission',
seen: true, seen: true,
tags: [],
}, },
]; ];
@ -143,6 +166,7 @@ describe('searchInFilteredData', () => {
name: 'my-feature-toggle-3', name: 'my-feature-toggle-3',
project: 'my-project', project: 'my-project',
stale: false, stale: false,
tags: [],
type: 'operational', type: 'operational',
seen: false, seen: false,
}, },
@ -150,6 +174,7 @@ describe('searchInFilteredData', () => {
name: 'my-feature-toggle-4', name: 'my-feature-toggle-4',
project: 'my-project', project: 'my-project',
stale: true, stale: true,
tags: [],
type: 'permission', type: 'permission',
seen: true, seen: true,
}, },
@ -162,6 +187,7 @@ describe('searchInFilteredData', () => {
name: 'my-feature-toggle-2', name: 'my-feature-toggle-2',
project: 'default', project: 'default',
stale: true, stale: true,
tags: [],
type: 'experiment', type: 'experiment',
seen: false, seen: false,
}, },
@ -187,6 +213,7 @@ describe('searchInFilteredData', () => {
name: 'my-feature-toggle-2', name: 'my-feature-toggle-2',
project: 'default', project: 'default',
stale: true, stale: true,
tags: [],
type: 'experiment', type: 'experiment',
seen: false, seen: false,
}, },
@ -201,6 +228,7 @@ describe('searchInFilteredData', () => {
name: 'my-feature-toggle-2', name: 'my-feature-toggle-2',
project: 'default', project: 'default',
stale: true, stale: true,
tags: [],
type: 'experiment', type: 'experiment',
seen: false, seen: false,
}, },
@ -208,6 +236,7 @@ describe('searchInFilteredData', () => {
name: 'my-feature-toggle-3', name: 'my-feature-toggle-3',
project: 'my-project', project: 'my-project',
stale: false, stale: false,
tags: [],
type: 'operational', type: 'operational',
seen: false, seen: false,
}, },
@ -225,6 +254,10 @@ describe('filter', () => {
name: 'my-feature-toggle', name: 'my-feature-toggle',
project: 'default', project: 'default',
stale: false, stale: false,
tags: [
{ type: 'simple', value: 'tag' },
{ type: 'simple', value: 'some space' },
],
type: 'release', type: 'release',
seen: true, seen: true,
}, },
@ -232,6 +265,7 @@ describe('filter', () => {
name: 'my-feature-toggle-2', name: 'my-feature-toggle-2',
project: 'default', project: 'default',
stale: true, stale: true,
tags: [],
type: 'experiment', type: 'experiment',
seen: false, seen: false,
}, },
@ -244,6 +278,10 @@ describe('filter', () => {
name: 'my-feature-toggle', name: 'my-feature-toggle',
project: 'default', project: 'default',
stale: false, stale: false,
tags: [
{ type: 'simple', value: 'tag' },
{ type: 'simple', value: 'some space' },
],
type: 'release', type: 'release',
seen: true, seen: true,
}, },
@ -251,6 +289,7 @@ describe('filter', () => {
name: 'my-feature-toggle-3', name: 'my-feature-toggle-3',
project: 'my-project', project: 'my-project',
stale: false, stale: false,
tags: [],
type: 'operational', type: 'operational',
seen: false, seen: false,
}, },
@ -276,6 +315,7 @@ describe('filter', () => {
name: 'my-feature-toggle-3', name: 'my-feature-toggle-3',
project: 'my-project', project: 'my-project',
stale: false, stale: false,
tags: [],
type: 'operational', type: 'operational',
seen: false, seen: false,
}, },
@ -283,6 +323,7 @@ describe('filter', () => {
name: 'my-feature-toggle-4', name: 'my-feature-toggle-4',
project: 'my-project', project: 'my-project',
stale: true, stale: true,
tags: [],
type: 'permission', type: 'permission',
seen: true, seen: true,
}, },
@ -297,6 +338,7 @@ describe('filter', () => {
name: 'my-feature-toggle-2', name: 'my-feature-toggle-2',
project: 'default', project: 'default',
stale: true, stale: true,
tags: [],
type: 'experiment', type: 'experiment',
seen: false, seen: false,
}, },
@ -304,9 +346,154 @@ describe('filter', () => {
name: 'my-feature-toggle-4', name: 'my-feature-toggle-4',
project: 'my-project', project: 'my-project',
stale: true, stale: true,
tags: [],
type: 'permission', type: 'permission',
seen: true, seen: true,
}, },
]); ]);
}); });
}); });
const SearchData: FC<{ searchValue: string }> = ({ searchValue }) => {
const search = useSearch(columns, searchValue, data);
return <div>{search.data.map(item => item.name).join(',')}</div>;
};
const SearchText: FC<{ searchValue: string }> = ({ searchValue }) => {
const search = useSearch(columns, searchValue, data);
return <div>{search.getSearchText(searchValue)}</div>;
};
describe('Search and filter data', () => {
it('should filter single value', () => {
render(<SearchData searchValue={'project:my-project'} />);
screen.getByText('my-feature-toggle-3,my-feature-toggle-4');
});
it('should filter multiple values', () => {
render(<SearchData searchValue={'project:my-project,another-value'} />);
screen.getByText('my-feature-toggle-3,my-feature-toggle-4');
});
it('should filter multiple values with spaces', () => {
render(
<SearchData searchValue={'project:my-project , another-value'} />
);
screen.getByText('my-feature-toggle-3,my-feature-toggle-4');
});
it('should handle multiple filters', () => {
render(
<SearchData
searchValue={'project:my-project ,another-value state:active'}
/>
);
screen.getByText('my-feature-toggle-3');
});
it('should handle multiple filters with long spaces', () => {
render(
<SearchData
searchValue={
'project:my-project , another-value state:active , stale'
}
/>
);
screen.getByText('my-feature-toggle-3,my-feature-toggle-4');
});
it('should handle multiple filters and search string in between', () => {
render(
<SearchData
searchValue={
'project:my-project , another-value toggle-3 state:active , stale'
}
/>
);
screen.getByText('my-feature-toggle-3');
});
it('should handle multiple filters and search string at the end', () => {
render(
<SearchData
searchValue={
'project:my-project , another-value state:active , stale toggle-3'
}
/>
);
screen.getByText('my-feature-toggle-3');
});
it('should handle multiple filters and search string at the beginning', () => {
render(
<SearchData
searchValue={
'toggle-3 project:my-project , another-value state:active , stale'
}
/>
);
screen.getByText('my-feature-toggle-3');
});
it('should return basic search text', () => {
render(<SearchText searchValue={'toggle-3'} />);
screen.getByText('toggle-3');
});
it('should return advanced search text', () => {
render(
<SearchText
searchValue={
'project:my-project , another-value toggle-3 state:active , stale'
}
/>
);
screen.getByText('toggle-3');
});
it('should support custom filter and accessor', () => {
render(<SearchData searchValue={'tags:simple:tag'} />);
screen.getByText('my-feature-toggle');
});
it('should support search on top of filter', () => {
render(<SearchText searchValue={'tags:simple:tag simple:tag'} />);
screen.getByText('simple:tag');
});
it('should support custom filter with spaces', () => {
render(<SearchData searchValue={'tags:"simple:some space",tag'} />);
screen.getByText('my-feature-toggle');
});
it('should support custom filter with spaces - space in second term', () => {
render(<SearchData searchValue={'tags:tag,"simple:some space"'} />);
screen.getByText('my-feature-toggle');
});
it('should support quotes in filter and search', () => {
render(
<SearchData
searchValue={'tags:tag,"simple:some space" "my-feature-toggle"'}
/>
);
screen.getByText('my-feature-toggle');
});
});

View File

@ -12,32 +12,45 @@ type IUseSearchOutput<T extends any> = {
getSearchContext: () => IGetSearchContextOutput<T>; getSearchContext: () => IGetSearchContextOutput<T>;
}; };
// 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 = <T extends any>( export const useSearch = <T extends any>(
columns: any[], columns: any[],
searchValue: string, searchValue: string,
data: T[] data: T[]
): IUseSearchOutput<T> => { ): IUseSearchOutput<T> => {
const getSearchText = useCallback( const getSearchText = useCallback(
(value: string) => getSearchTextGenerator(columns)(value), (value: string) =>
removeQuotes(
getSearchTextGenerator(columns)(normalizeSearchValue(value))
),
[columns] [columns]
); );
const normalizedSearchValue = normalizeSearchValue(searchValue);
const getSearchContext = useCallback(() => { const getSearchContext = useCallback(() => {
return { data, searchValue, columns }; return { data, searchValue: normalizedSearchValue, columns };
}, [data, searchValue, columns]); }, [data, normalizedSearchValue, columns]);
const search = useMemo(() => { 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( const searchedData = searchInFilteredData(
columns, columns,
getSearchText(searchValue), getSearchText(normalizedSearchValue),
filteredData filteredData
); );
return searchedData; return searchedData;
}, [columns, searchValue, data, getSearchText]); }, [columns, normalizedSearchValue, data, getSearchText]);
return { data: search, getSearchText, getSearchContext }; return { data: search, getSearchText, getSearchContext };
}; };
@ -67,6 +80,7 @@ export const searchInFilteredData = <T extends any>(
searchValue: string, searchValue: string,
filteredData: T[] filteredData: T[]
) => { ) => {
const trimmedSearchValue = searchValue.trim();
const searchableColumns = columns.filter( const searchableColumns = columns.filter(
column => column.searchable && column.accessor column => column.searchable && column.accessor
); );
@ -74,10 +88,13 @@ export const searchInFilteredData = <T extends any>(
return filteredData.filter(row => { return filteredData.filter(row => {
return searchableColumns.some(column => { return searchableColumns.some(column => {
if (column.searchBy) { 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 = <T extends any>(
const defaultFilter = (fieldValue: string, values: string[]) => const defaultFilter = (fieldValue: string, values: string[]) =>
values.some(value => fieldValue?.toLowerCase() === value?.toLowerCase()); 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) => const defaultSearch = (fieldValue: string, value: string) =>
fieldValue?.toLowerCase().includes(value?.toLowerCase()); fieldValue?.toLowerCase().includes(value?.toLowerCase());
@ -99,13 +121,14 @@ export const getSearchTextGenerator = (columns: any[]) => {
return (searchValue: string) => return (searchValue: string) =>
searchValue searchValue
.split(' ') .split(SPACES_WITHOUT_QUOTES)
.filter(fragment => !isValidSearch(fragment)) .filter(fragment => !isValidSearch(fragment))
.join(' '); .join(' ');
}; };
export const isValidFilter = (input: string, match: string) => 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[]) => export const getFilterableColumns = (columns: any[]) =>
columns.filter(column => column.filterName && column.accessor); 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) => export const getFilterValues = (filterName: string, searchValue: string) =>
searchValue searchValue
?.split(`${filterName}:`)[1] ?.split(`${filterName}:`)[1]
?.split(' ')[0] ?.split(SPACES_WITHOUT_QUOTES)[0]
?.split(',') ?.split(',')
.map(removeQuotes)
.filter(value => value) ?? []; .filter(value => value) ?? [];