From 5a7ff5c2ec21fa0f79a80a6952f65dd2d33a568e Mon Sep 17 00:00:00 2001 From: kwasniew Date: Thu, 26 Oct 2023 17:07:32 +0200 Subject: [PATCH 1/2] feat: filter by tags --- .../feature-search-controller.ts | 7 ++++- .../feature-search/feature-search-service.ts | 1 + .../feature-search/feature.search.e2e.test.ts | 31 +++++++++++++++++++ .../feature-toggle-strategies-store.ts | 8 +++++ .../feature-toggle-strategies-store-type.ts | 2 ++ .../spec/feature-search-query-parameters.ts | 13 ++++++++ 6 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts index bc417e0d47c9..ba0182ee1ac8 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -3,6 +3,7 @@ import Controller from '../../routes/controller'; import { FeatureSearchService, OpenApiService } from '../../services'; import { IFlagResolver, + ITag, IUnleashConfig, IUnleashServices, NONE, @@ -71,13 +72,17 @@ export default class FeatureSearchController extends Controller { res: Response, ): Promise { if (this.config.flagResolver.isEnabled('featureSearchAPI')) { - const { query, projectId, type } = req.query; + const { query, projectId, type, tag } = req.query; const userId = req.user.id; + const normalizedTag = tag + ?.map((t) => t.split(':')) + .filter((t) => t.length === 2); const features = await this.featureSearchService.search({ query, projectId, type, userId, + tag: normalizedTag, }); res.json({ features }); } else { diff --git a/src/lib/features/feature-search/feature-search-service.ts b/src/lib/features/feature-search/feature-search-service.ts index 65b39ae85639..1aca623dbdf4 100644 --- a/src/lib/features/feature-search/feature-search-service.ts +++ b/src/lib/features/feature-search/feature-search-service.ts @@ -25,6 +25,7 @@ export class FeatureSearchService { query: params.query, userId: params.userId, type: params.type, + tag: params.tag, }); return features; 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 f7fd45fbfaf7..007477404ce2 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -50,6 +50,13 @@ const filterFeaturesByType = async (types: string[], expectedCode = 200) => { .expect(expectedCode); }; +const filterFeaturesByTag = async (tags: string[], expectedCode = 200) => { + const tagParams = tags.map((tag) => `tag[]=${tag}`).join('&'); + return app.request + .get(`/api/admin/search/features?${tagParams}`) + .expect(expectedCode); +}; + const searchFeaturesWithoutQueryParams = async (expectedCode = 200) => { return app.request.get(`/api/admin/search/features`).expect(expectedCode); }; @@ -80,6 +87,30 @@ test('should filter features by type', async () => { }); }); +test('should filter features by tag', async () => { + await app.createFeature('my_feature_a'); + await app.createFeature('my_feature_b'); + await app.addTag('my_feature_a', { type: 'simple', value: 'my_tag' }); + + const { body } = await filterFeaturesByTag(['simple:my_tag']); + + expect(body).toMatchObject({ + features: [{ name: 'my_feature_a' }], + }); +}); + +test('filter with invalid tag should ignore filter', async () => { + await app.createFeature('my_feature_a'); + await app.createFeature('my_feature_b'); + await app.addTag('my_feature_a', { type: 'simple', value: 'my_tag' }); + + const { body } = await filterFeaturesByTag(['simple']); + + expect(body).toMatchObject({ + features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }], + }); +}); + test('should search matching features by tag', async () => { await app.createFeature('my_feature_a'); await app.createFeature('my_feature_b'); diff --git a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts index 40d8c9d34b4b..8575330fdf30 100644 --- a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts @@ -521,6 +521,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { userId, query: queryString, type, + tag, }: IFeatureSearchParams): Promise { let query = this.db('features'); if (projectId) { @@ -542,6 +543,13 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { .whereILike('features.name', `%${queryString}%`) .orWhereIn('features.name', tagQuery); } + if (tag && tag.length > 0) { + const tagQuery = this.db + .from('feature_tag') + .select('feature_name') + .whereIn(['tag_type', 'tag_value'], tag); + query = query.whereIn('features.name', tagQuery); + } if (type) { query = query.whereIn('features.type', type); } 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 5a960e3ad7d1..32acf6dbda01 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 @@ -4,6 +4,7 @@ import { IFeatureOverview, IFeatureStrategy, IStrategyConfig, + ITag, IVariant, } from '../../../types/model'; import { Store } from '../../../types/stores/store'; @@ -25,6 +26,7 @@ export interface IFeatureSearchParams { query?: string; projectId?: string; type?: string[]; + tag?: string[][]; } export interface IFeatureStrategiesStore diff --git a/src/lib/openapi/spec/feature-search-query-parameters.ts b/src/lib/openapi/spec/feature-search-query-parameters.ts index 420399843a33..7b92732c26c4 100644 --- a/src/lib/openapi/spec/feature-search-query-parameters.ts +++ b/src/lib/openapi/spec/feature-search-query-parameters.ts @@ -31,6 +31,19 @@ export const featureSearchQueryParameters = [ description: 'The list of feature types to filter by', in: 'query', }, + { + name: 'tag', + schema: { + type: 'array', + items: { + type: 'string', + example: 'simple:my_tag', + }, + }, + description: + 'The list of feature tags to filter by. Feature tag has to specify type and value joined with a colon.', + in: 'query', + }, ] as const; export type FeatureSearchQueryParameters = Partial< From e7d0b3b0f27f9a8784e318ab055b7b5416c6e528 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Thu, 26 Oct 2023 17:12:08 +0200 Subject: [PATCH 2/2] feat: filter by tags --- src/lib/features/feature-search/feature-search-controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts index ba0182ee1ac8..0829ea4e83e4 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -75,8 +75,8 @@ export default class FeatureSearchController extends Controller { const { query, projectId, type, tag } = req.query; const userId = req.user.id; const normalizedTag = tag - ?.map((t) => t.split(':')) - .filter((t) => t.length === 2); + ?.map((tag) => tag.split(':')) + .filter((tag) => tag.length === 2); const features = await this.featureSearchService.search({ query, projectId,