diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts index 98a85cf2b5..b63b44bac2 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -81,6 +81,7 @@ export default class FeatureSearchController extends Controller { limit = '50', sortOrder, sortBy, + favoritesFirst, } = req.query; const userId = req.user.id; const normalizedTag = tag?.map((tag) => tag.split(':')); @@ -97,6 +98,7 @@ export default class FeatureSearchController extends Controller { const normalizedSortBy: string = sortBy ? sortBy : 'createdAt'; const normalizedSortOrder = sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc'; + const normalizedFavoritesFirst = favoritesFirst === 'true'; const { features, total } = await this.featureSearchService.search({ query, projectId, @@ -108,6 +110,7 @@ export default class FeatureSearchController extends Controller { limit: normalizedLimit, sortBy: normalizedSortBy, sortOrder: normalizedSortOrder, + favoritesFirst: normalizedFavoritesFirst, }); res.json({ features, total }); 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 a638b2c98d..9811275fd0 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -1,6 +1,7 @@ import dbInit, { ITestDb } from '../../../test/e2e/helpers/database-init'; import { IUnleashTest, + setupAppWithAuth, setupAppWithCustomConfig, } from '../../../test/e2e/helpers/test-helper'; import getLogger from '../../../test/fixtures/no-logger'; @@ -11,7 +12,7 @@ let db: ITestDb; beforeAll(async () => { db = await dbInit('feature_search', getLogger); - app = await setupAppWithCustomConfig( + app = await setupAppWithAuth( db.stores, { experimental: { @@ -23,6 +24,13 @@ beforeAll(async () => { }, db.rawDatabase, ); + + await app.request + .post(`/auth/demo/login`) + .send({ + email: 'user@getunleash.io', + }) + .expect(200); }); afterAll(async () => { @@ -48,12 +56,13 @@ const sortFeatures = async ( sortBy = '', sortOrder = '', projectId = 'default', + favoritesFirst = 'false', }: FeatureSearchQueryParameters, expectedCode = 200, ) => { return app.request .get( - `/api/admin/search/features?sortBy=${sortBy}&sortOrder=${sortOrder}&projectId=${projectId}`, + `/api/admin/search/features?sortBy=${sortBy}&sortOrder=${sortOrder}&projectId=${projectId}&favoritesFirst=${favoritesFirst}`, ) .expect(expectedCode); }; @@ -149,8 +158,14 @@ test('should paginate with offset', async () => { }); test('should filter features by type', async () => { - await app.createFeature({ name: 'my_feature_a', type: 'release' }); - await app.createFeature({ name: 'my_feature_b', type: 'experimental' }); + await app.createFeature({ + name: 'my_feature_a', + type: 'release', + }); + await app.createFeature({ + name: 'my_feature_b', + type: 'experimental', + }); const { body } = await filterFeaturesByType([ 'experimental', @@ -165,7 +180,10 @@ 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' }); + await app.addTag('my_feature_a', { + type: 'simple', + value: 'my_tag', + }); const { body } = await filterFeaturesByTag(['simple:my_tag']); @@ -193,7 +211,10 @@ test('should filter features by environment status', async () => { test('should filter by partial 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' }); + await app.addTag('my_feature_a', { + type: 'simple', + value: 'my_tag', + }); const { body } = await filterFeaturesByTag(['simple']); @@ -205,7 +226,10 @@ test('should filter by partial tag', async () => { test('should search matching 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' }); + await app.addTag('my_feature_a', { + type: 'simple', + value: 'my_tag', + }); const { body: fullMatch } = await searchFeatures({ query: 'simple:my_tag', @@ -230,8 +254,14 @@ test('should search matching features by tag', async () => { test('should return all feature tags', async () => { await app.createFeature('my_feature_a'); - await app.addTag('my_feature_a', { type: 'simple', value: 'my_tag' }); - await app.addTag('my_feature_a', { type: 'simple', value: 'second_tag' }); + await app.addTag('my_feature_a', { + type: 'simple', + value: 'my_tag', + }); + await app.addTag('my_feature_a', { + type: 'simple', + value: 'second_tag', + }); const { body } = await searchFeatures({}); @@ -240,8 +270,14 @@ test('should return all feature tags', async () => { { name: 'my_feature_a', tags: [ - { type: 'simple', value: 'my_tag' }, - { type: 'simple', value: 'second_tag' }, + { + type: 'simple', + value: 'my_tag', + }, + { + type: 'simple', + value: 'second_tag', + }, ], }, ], @@ -281,6 +317,7 @@ test('should sort features', async () => { await app.createFeature('my_feature_c'); await app.createFeature('my_feature_b'); await app.enableFeature('my_feature_c', 'default'); + await app.favoriteFeature('my_feature_b'); const { body: ascName } = await sortFeatures({ sortBy: 'name', @@ -351,4 +388,19 @@ test('should sort features', async () => { ], total: 3, }); + + const { body: favoriteEnvironmentDescSort } = await sortFeatures({ + sortBy: 'environment:default', + sortOrder: 'desc', + favoritesFirst: 'true', + }); + + expect(favoriteEnvironmentDescSort).toMatchObject({ + features: [ + { name: 'my_feature_b' }, + { name: 'my_feature_c' }, + { name: 'my_feature_a' }, + ], + total: 3, + }); }); 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 0f072bb0dc..0f790a368f 100644 --- a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts @@ -530,6 +530,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { limit, sortOrder, sortBy, + favoritesFirst, }: IFeatureSearchParams): Promise<{ features: IFeatureOverview[]; total: number; @@ -706,6 +707,10 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { lastSeenAt: 'env_last_seen_at', }; + if (favoritesFirst) { + query = query.orderBy('favorite', 'desc'); + } + if (sortBy.startsWith('environment:')) { const [, envName] = sortBy.split(':'); query = query 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 b944caaa49..331c9b40c5 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 @@ -29,6 +29,7 @@ export interface IFeatureSearchParams { tag?: string[][]; status?: string[][]; offset: number; + favoritesFirst?: boolean; limit: number; sortBy: string; sortOrder: 'asc' | 'desc'; diff --git a/src/lib/openapi/spec/feature-search-query-parameters.ts b/src/lib/openapi/spec/feature-search-query-parameters.ts index 84a8cda139..90ab7b391a 100644 --- a/src/lib/openapi/spec/feature-search-query-parameters.ts +++ b/src/lib/openapi/spec/feature-search-query-parameters.ts @@ -97,6 +97,16 @@ export const featureSearchQueryParameters = [ 'The sort order for the sortBy. By default it is det to "asc".', in: 'query', }, + { + name: 'favoritesFirst', + schema: { + type: 'string', + example: 'true', + }, + description: + 'The flag to indicate if the favorite features should be returned first. By default it is set to false.', + in: 'query', + }, ] as const; export type FeatureSearchQueryParameters = Partial< diff --git a/src/test/e2e/helpers/test-helper.ts b/src/test/e2e/helpers/test-helper.ts index 8dfebf6bf3..e1056857da 100644 --- a/src/test/e2e/helpers/test-helper.ts +++ b/src/test/e2e/helpers/test-helper.ts @@ -54,6 +54,12 @@ export interface IUnleashHttpAPI { expectedResponseCode?: number, ): supertest.Test; + favoriteFeature( + feature: string, + project?: string, + expectedResponseCode?: number, + ): supertest.Test; + getFeatures(name?: string, expectedResponseCode?: number): supertest.Test; getProjectFeatures( @@ -239,6 +245,19 @@ function httpApis( ) .expect(expectedResponseCode); }, + + favoriteFeature( + feature: string, + project = 'default', + expectedResponseCode = 200, + ): supertest.Test { + return request + .post( + `/api/admin/projects/${project}/features/${feature}/favorites`, + ) + .set('Content-Type', 'application/json') + .expect(expectedResponseCode); + }, }; }