From 588e35e759b5a9f3382b8aac044289d448ed93b4 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Mon, 7 Apr 2025 14:00:01 +0200 Subject: [PATCH] feat: search by lifecycle stage (#9705) --- .../feature-search-controller.ts | 2 ++ .../feature-search/feature-search-store.ts | 15 +++++++- .../feature-search/feature.search.e2e.test.ts | 36 +++++++++++++++++-- .../feature-toggle-strategies-store-type.ts | 1 + .../spec/feature-search-query-parameters.ts | 12 +++++++ 5 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts index a82ca6bde2..183f6da678 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -101,6 +101,7 @@ export default class FeatureSearchController extends Controller { createdBy, state, status, + lifecycle, favoritesFirst, archived, sortBy, @@ -144,6 +145,7 @@ export default class FeatureSearchController extends Controller { createdAt, createdBy, sortBy, + lifecycle, status: normalizedStatus, offset: normalizedOffset, limit: normalizedLimit, diff --git a/src/lib/features/feature-search/feature-search-store.ts b/src/lib/features/feature-search/feature-search-store.ts index a0116acdb5..96344df9a3 100644 --- a/src/lib/features/feature-search/feature-search-store.ts +++ b/src/lib/features/feature-search/feature-search-store.ts @@ -15,7 +15,11 @@ import type { IFeatureSearchParams, IQueryParam, } from '../feature-toggle/types/feature-toggle-strategies-store-type'; -import { applyGenericQueryParams, applySearchFilters } from './search-utils'; +import { + applyGenericQueryParams, + applySearchFilters, + parseSearchOperatorValue, +} from './search-utils'; import type { FeatureSearchEnvironmentSchema } from '../../openapi/spec/feature-search-environment-schema'; import { generateImageUrl } from '../../util'; import Raw = Knex.Raw; @@ -100,6 +104,7 @@ class FeatureSearchStore implements IFeatureSearchStore { status, offset, limit, + lifecycle, sortOrder, sortBy, archived, @@ -327,7 +332,15 @@ class FeatureSearchStore implements IFeatureSearchStore { 'ranked_features.feature_name', 'lifecycle.stage_feature', ); + if (this.flagResolver.isEnabled('flagsOverviewSearch')) { + const parsedLifecycle = lifecycle + ? parseSearchOperatorValue('lifecycle.latest_stage', lifecycle) + : null; + if (parsedLifecycle) { + applyGenericQueryParams(finalQuery, [parsedLifecycle]); + } + finalQuery .leftJoin( this.db('change_request_events AS cre') 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 5fbf07f8ce..f226da0b8c 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -103,6 +103,22 @@ const searchFeatures = async ( .expect(expectedCode); }; +const searchFeaturesWithLifecycle = async ( + { + query = '', + project = 'IS:default', + archived = 'IS:false', + lifecycle = 'IS:initial', + }: FeatureSearchQueryParameters, + expectedCode = 200, +) => { + return app.request + .get( + `/api/admin/search/features?query=${query}&project=${project}&archived=${archived}&lifecycle=${lifecycle}`, + ) + .expect(expectedCode); +}; + const sortFeatures = async ( { sortBy = '', @@ -1130,10 +1146,10 @@ test('should return environment usage metrics and lifecycle', async () => { { feature: 'my_feature_b', stage: 'completed', status: 'discarded' }, ]); - const { body } = await searchFeatures({ + const { body: noExplicitLifecycle } = await searchFeatures({ query: 'my_feature_b', }); - expect(body).toMatchObject({ + expect(noExplicitLifecycle).toMatchObject({ features: [ { name: 'my_feature_b', @@ -1158,6 +1174,22 @@ test('should return environment usage metrics and lifecycle', async () => { }, ], }); + + const { body: noFeaturesWithOtherLifecycle } = + await searchFeaturesWithLifecycle({ + query: 'my_feature_b', + lifecycle: 'IS:initial', + }); + expect(noFeaturesWithOtherLifecycle).toMatchObject({ features: [] }); + + const { body: featureWithMatchingLifecycle } = + await searchFeaturesWithLifecycle({ + query: 'my_feature_b', + lifecycle: 'IS:completed', + }); + expect(featureWithMatchingLifecycle).toMatchObject({ + features: [{ name: 'my_feature_b' }], + }); }); test('should return dependencyType', async () => { 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 6998dc1d02..57ad29372d 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 @@ -30,6 +30,7 @@ export interface IFeatureSearchParams { state?: string; type?: string; tag?: string; + lifecycle?: string; status?: string[][]; offset: number; favoritesFirst?: boolean; diff --git a/src/lib/openapi/spec/feature-search-query-parameters.ts b/src/lib/openapi/spec/feature-search-query-parameters.ts index c360331475..7d0937dd2c 100644 --- a/src/lib/openapi/spec/feature-search-query-parameters.ts +++ b/src/lib/openapi/spec/feature-search-query-parameters.ts @@ -34,6 +34,18 @@ export const featureSearchQueryParameters = [ 'The state of the feature active/stale. The state can be specified with an operator. The supported operators are IS, IS_NOT, IS_ANY_OF, IS_NONE_OF.', in: 'query', }, + { + name: 'lifecycle', + schema: { + type: 'string', + example: 'IS:initial', + pattern: + '^(IS|IS_NOT|IS_ANY_OF|IS_NONE_OF):(.*?)(,([a-zA-Z0-9_]+))*$', + }, + description: + 'The lifecycle stage of the feature. The stagee can be specified with an operator. The supported operators are IS, IS_NOT, IS_ANY_OF, IS_NONE_OF.', + in: 'query', + }, { name: 'type', schema: {