From 43298e16e24fe44bbade6d1391b6b6ce69311ec2 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Fri, 3 Nov 2023 13:15:12 +0100 Subject: [PATCH] feat: Server side sort by (#5250) --- .../feature-search-controller.ts | 7 ++ .../feature-search/feature.search.e2e.test.ts | 92 +++++++++++++++++++ .../feature-toggle-strategies-store.ts | 24 ++++- .../feature-toggle-strategies-store-type.ts | 2 + .../spec/feature-search-query-parameters.ts | 22 ++++- 5 files changed, 145 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 4de715c5d9ff..5e0363e425b9 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -81,6 +81,8 @@ export default class FeatureSearchController extends Controller { status, cursor, limit = '50', + sortOrder, + sortBy, } = req.query; const userId = req.user.id; const normalizedTag = tag @@ -95,6 +97,9 @@ export default class FeatureSearchController extends Controller { ); const normalizedLimit = Number(limit) > 0 && Number(limit) <= 50 ? Number(limit) : 50; + const normalizedSortBy: string = sortBy ? sortBy : 'createdAt'; + const normalizedSortOrder = + sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc'; const { features, nextCursor, total } = await this.featureSearchService.search({ query, @@ -105,6 +110,8 @@ export default class FeatureSearchController extends Controller { status: normalizedStatus, cursor, limit: normalizedLimit, + sortBy: normalizedSortBy, + sortOrder: normalizedSortOrder, }); res.header('Link', nextLink(req, nextCursor)); 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 1fee19337022..7fd32ba70bc8 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -43,6 +43,21 @@ const searchFeatures = async ( .expect(expectedCode); }; +const sortFeatures = async ( + { + sortBy = '', + sortOrder = '', + projectId = 'default', + }: FeatureSearchQueryParameters, + expectedCode = 200, +) => { + return app.request + .get( + `/api/admin/search/features?sortBy=${sortBy}&sortOrder=${sortOrder}&projectId=${projectId}`, + ) + .expect(expectedCode); +}; + const searchFeaturesWithCursor = async ( { query = '', @@ -243,3 +258,80 @@ test('should return features without query', async () => { features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }], }); }); + +test('should sort features', async () => { + await app.createFeature('my_feature_a'); + await app.createFeature('my_feature_c'); + await app.createFeature('my_feature_b'); + await app.enableFeature('my_feature_c', 'default'); + + const { body: ascName } = await sortFeatures({ + sortBy: 'name', + sortOrder: 'asc', + }); + + expect(ascName).toMatchObject({ + features: [ + { name: 'my_feature_a' }, + { name: 'my_feature_b' }, + { name: 'my_feature_c' }, + ], + total: 3, + }); + + const { body: descName } = await sortFeatures({ + sortBy: 'name', + sortOrder: 'desc', + }); + + expect(descName).toMatchObject({ + features: [ + { name: 'my_feature_c' }, + { name: 'my_feature_b' }, + { name: 'my_feature_a' }, + ], + total: 3, + }); + + const { body: defaultCreatedAt } = await sortFeatures({ + sortBy: '', + sortOrder: '', + }); + + expect(defaultCreatedAt).toMatchObject({ + features: [ + { name: 'my_feature_a' }, + { name: 'my_feature_c' }, + { name: 'my_feature_b' }, + ], + total: 3, + }); + + const { body: environmentAscSort } = await sortFeatures({ + sortBy: 'environment:default', + sortOrder: 'asc', + }); + + expect(environmentAscSort).toMatchObject({ + features: [ + { name: 'my_feature_a' }, + { name: 'my_feature_b' }, + { name: 'my_feature_c' }, + ], + total: 3, + }); + + const { body: environmentDescSort } = await sortFeatures({ + sortBy: 'environment:default', + sortOrder: 'desc', + }); + + expect(environmentDescSort).toMatchObject({ + features: [ + { name: 'my_feature_c' }, + { name: 'my_feature_a' }, + { name: 'my_feature_b' }, + ], + 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 7ac4ee5585d4..bda7ea6cefa7 100644 --- a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts @@ -532,6 +532,8 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { status, cursor, limit, + sortOrder, + sortBy, }: IFeatureSearchParams): Promise<{ features: IFeatureOverview[]; total: number; @@ -681,7 +683,27 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { if (cursor) { query = query.where('features.created_at', '>=', cursor); } - query = query.orderBy('features.created_at', 'asc'); + + const sortByMapping = { + name: 'feature_name', + type: 'type', + lastSeenAt: 'env_last_seen_at', + }; + if (sortBy.startsWith('environment:')) { + const [, envName] = sortBy.split(':'); + query = query + .orderByRaw( + `CASE WHEN feature_environments.environment = ? THEN feature_environments.enabled ELSE NULL END ${sortOrder}`, + [envName], + ) + .orderBy('created_at', 'asc'); + } else if (sortByMapping[sortBy]) { + query = query + .orderBy(sortByMapping[sortBy], sortOrder) + .orderBy('created_at', 'asc'); + } else { + query = query.orderBy('created_at', sortOrder); + } const total = await countQuery .countDistinct({ total: 'features.name' }) 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 f27753c69d1c..86c4d0b2247d 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,8 @@ export interface IFeatureSearchParams { status?: string[][]; limit: number; cursor?: string; + sortBy: string; + sortOrder: 'asc' | 'desc'; } 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 7c5bcaa41ed4..9c5e838c8b79 100644 --- a/src/lib/openapi/spec/feature-search-query-parameters.ts +++ b/src/lib/openapi/spec/feature-search-query-parameters.ts @@ -7,7 +7,7 @@ export const featureSearchQueryParameters = [ type: 'string', example: 'feature_a', }, - description: 'The search query for the feature or tag', + description: 'The search query for the feature name or tag', in: 'query', }, { @@ -77,6 +77,26 @@ export const featureSearchQueryParameters = [ 'The number of feature environments to return in a page. By default it is set to 50.', in: 'query', }, + { + name: 'sortBy', + schema: { + type: 'string', + example: 'type', + }, + description: + 'The field to sort the results by. By default it is set to "createdAt".', + in: 'query', + }, + { + name: 'sortOrder', + schema: { + type: 'string', + example: 'desc', + }, + description: + 'The sort order for the sortBy. By default it is det to "asc".', + in: 'query', + }, ] as const; export type FeatureSearchQueryParameters = Partial<