From a91b77a7ce6fbca8ef0945927493626524854ca3 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Thu, 6 Jun 2024 12:59:11 +0200 Subject: [PATCH] feat: filter by created by (#7306) --- .../ProjectFeatureToggles.tsx | 7 ++-- .../ProjectOverviewFilters.tsx | 23 ++++++++++++- .../useProjectFeatureSearch.ts | 3 ++ .../useProjectFlagCreators.ts | 13 ++++++++ .../feature-search-controller.ts | 2 ++ .../feature-search/feature-search-service.ts | 8 +++++ .../feature-search/feature.search.e2e.test.ts | 32 +++++++++++++++++++ .../feature-toggle-strategies-store-type.ts | 1 + .../spec/feature-search-query-parameters.ts | 12 +++++++ 9 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 frontend/src/hooks/api/getters/useProjectFlagCreators/useProjectFlagCreators.ts diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx index 7dbb5c5868..e7f9c3b4f1 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -55,6 +55,8 @@ export const ProjectFeatureToggles = ({ environments, }: IPaginatedProjectFeatureTogglesProps) => { const projectId = useRequiredPathParam('projectId'); + const featureLifecycleEnabled = useUiFlag('featureLifecycle'); + const flagCreatorEnabled = useUiFlag('flagCreator'); const { features, @@ -75,6 +77,7 @@ export const ProjectFeatureToggles = ({ tag: tableState.tag, createdAt: tableState.createdAt, type: tableState.type, + ...(flagCreatorEnabled ? { createdBy: tableState.createdBy } : {}), }; const { favorite, unfavorite } = useFavoriteFeaturesApi(); @@ -101,9 +104,6 @@ export const ProjectFeatureToggles = ({ const isPlaceholder = Boolean(initialLoad || (loading && total)); - const featureLifecycleEnabled = useUiFlag('featureLifecycle'); - const flagCreatorEnabled = useUiFlag('flagCreator'); - const columns = useMemo( () => [ columnHelper.display({ @@ -490,6 +490,7 @@ export const ProjectFeatureToggles = ({ aria-live='polite' > diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx index bb411aec57..fa8b6ff92c 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx @@ -5,18 +5,24 @@ import { Filters, type IFilterItem, } from 'component/filter/Filters/Filters'; +import { useProjectFlagCreators } from 'hooks/api/getters/useProjectFlagCreators/useProjectFlagCreators'; +import { useUiFlag } from 'hooks/useUiFlag'; interface IProjectOverviewFilters { state: FilterItemParamHolder; onChange: (value: FilterItemParamHolder) => void; + project: string; } export const ProjectOverviewFilters: VFC = ({ state, onChange, + project, }) => { const { tags } = useAllTags(); + const { flagCreators } = useProjectFlagCreators(project); const [availableFilters, setAvailableFilters] = useState([]); + const flagCreatorEnabled = useUiFlag('flagCreator'); useEffect(() => { const tagsOptions = (tags || []).map((tag) => ({ @@ -24,6 +30,11 @@ export const ProjectOverviewFilters: VFC = ({ value: `${tag.type}:${tag.value}`, })); + const flagCreatorsOptions = flagCreators.map((creator) => ({ + label: creator.name, + value: String(creator.id), + })); + const availableFilters: IFilterItem[] = [ { label: 'Tags', @@ -60,9 +71,19 @@ export const ProjectOverviewFilters: VFC = ({ pluralOperators: ['IS_ANY_OF', 'IS_NONE_OF'], }, ]; + if (flagCreatorEnabled) { + availableFilters.push({ + label: 'Created by', + icon: 'person', + options: flagCreatorsOptions, + filterKey: 'createdBy', + singularOperators: ['IS', 'IS_NOT'], + pluralOperators: ['IS_ANY_OF', 'IS_NONE_OF'], + }); + } setAvailableFilters(availableFilters); - }, [JSON.stringify(tags)]); + }, [JSON.stringify(tags), JSON.stringify(flagCreators)]); return ( { + const flagCreatorEnabled = useUiFlag('flagCreator'); const stateConfig = { offset: withDefault(NumberParam, 0), limit: withDefault(NumberParam, DEFAULT_PAGE_LIMIT), @@ -36,6 +38,7 @@ export const useProjectFeatureSearch = ( tag: FilterItemParam, createdAt: FilterItemParam, type: FilterItemParam, + ...(flagCreatorEnabled ? { createdBy: FilterItemParam } : {}), }; const [tableState, setTableState] = usePersistentTableState( `${storageKey}-${projectId}`, diff --git a/frontend/src/hooks/api/getters/useProjectFlagCreators/useProjectFlagCreators.ts b/frontend/src/hooks/api/getters/useProjectFlagCreators/useProjectFlagCreators.ts new file mode 100644 index 0000000000..6b84cb4bc6 --- /dev/null +++ b/frontend/src/hooks/api/getters/useProjectFlagCreators/useProjectFlagCreators.ts @@ -0,0 +1,13 @@ +import { fetcher, useApiGetter } from '../useApiGetter/useApiGetter'; +import { formatApiPath } from 'utils/formatPath'; +import type { ProjectFlagCreatorsSchema } from '../../../../openapi'; + +export const useProjectFlagCreators = (project: string) => { + const PATH = `api/admin/projects/${project}/flag-creators`; + const { data, refetch, loading, error } = + useApiGetter(formatApiPath(PATH), () => + fetcher(formatApiPath(PATH), 'Flag creators'), + ); + + return { flagCreators: data || [], refetch, error }; +}; diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts index ef1706d541..ea56a4cc33 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -82,6 +82,7 @@ export default class FeatureSearchController extends Controller { tag, segment, createdAt, + createdBy, state, status, favoritesFirst, @@ -116,6 +117,7 @@ export default class FeatureSearchController extends Controller { segment, state, createdAt, + createdBy, status: normalizedStatus, offset: normalizedOffset, limit: normalizedLimit, diff --git a/src/lib/features/feature-search/feature-search-service.ts b/src/lib/features/feature-search/feature-search-service.ts index 2d9ed8a827..d82cb5fecd 100644 --- a/src/lib/features/feature-search/feature-search-service.ts +++ b/src/lib/features/feature-search/feature-search-service.ts @@ -75,6 +75,14 @@ export class FeatureSearchService { if (parsed) queryParams.push(parsed); } + if (params.createdBy) { + const parsed = this.parseOperatorValue( + 'users.id', + params.createdBy, + ); + if (parsed) queryParams.push(parsed); + } + if (params.type) { const parsed = this.parseOperatorValue( 'features.type', diff --git a/src/lib/features/feature-search/feature.search.e2e.test.ts b/src/lib/features/feature-search/feature.search.e2e.test.ts index 5bd2f6c05b..2a71e19237 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -108,6 +108,15 @@ const filterFeaturesByType = async (typeParams: string, expectedCode = 200) => { .expect(expectedCode); }; +const filterFeaturesByCreatedBy = async ( + createdByParams: string, + expectedCode = 200, +) => { + return app.request + .get(`/api/admin/search/features?createdBy=${createdByParams}`) + .expect(expectedCode); +}; + const filterFeaturesByTag = async (tag: string, expectedCode = 200) => { return app.request .get(`/api/admin/search/features?tag=${tag}`) @@ -246,6 +255,29 @@ test('should filter features by type', async () => { }); }); +test('should filter features by created by', async () => { + await app.createFeature({ + name: 'my_feature_a', + type: 'release', + }); + await app.createFeature({ + name: 'my_feature_b', + type: 'experimental', + }); + + const { body } = await filterFeaturesByCreatedBy('IS:1'); + + expect(body).toMatchObject({ + features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }], + }); + + const { body: emptyResults } = await filterFeaturesByCreatedBy('IS:2'); + + expect(emptyResults).toMatchObject({ + features: [], + }); +}); + test('should filter features by tag', async () => { await app.createFeature('my_feature_a'); await app.addTag('my_feature_a', { diff --git a/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts b/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts index dd3bc8ccb9..aa01ebb61d 100644 --- a/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts +++ b/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts @@ -26,6 +26,7 @@ export interface IFeatureSearchParams { project?: string; segment?: string; createdAt?: string; + createdBy?: string; state?: string; type?: string; tag?: string; diff --git a/src/lib/openapi/spec/feature-search-query-parameters.ts b/src/lib/openapi/spec/feature-search-query-parameters.ts index 91f450c03e..4559a731d0 100644 --- a/src/lib/openapi/spec/feature-search-query-parameters.ts +++ b/src/lib/openapi/spec/feature-search-query-parameters.ts @@ -46,6 +46,18 @@ export const featureSearchQueryParameters = [ 'The feature flag type to filter by. The type can be specified with an operator. The supported operators are IS, IS_NOT, IS_ANY_OF, IS_NONE_OF.', in: 'query', }, + { + name: 'createdBy', + schema: { + type: 'string', + example: 'IS:1', + pattern: + '^(IS|IS_NOT|IS_ANY_OF|IS_NONE_OF):(.*?)(,([a-zA-Z0-9_]+))*$', + }, + description: + 'The feature flag creator to filter by. The creators can be specified with an operator. The supported operators are IS, IS_NOT, IS_ANY_OF, IS_NONE_OF.', + in: 'query', + }, { name: 'tag', schema: {