diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts index d04bc1eea6..89a1ddfb7a 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -72,7 +72,15 @@ export default class FeatureSearchController extends Controller { res: Response, ): Promise { if (this.config.flagResolver.isEnabled('featureSearchAPI')) { - const { query, projectId, type, tag, status } = req.query; + const { + query, + projectId, + type, + tag, + status, + cursor, + limit = 50, + } = req.query; const userId = req.user.id; const normalizedTag = tag ?.map((tag) => tag.split(':')) @@ -84,6 +92,7 @@ export default class FeatureSearchController extends Controller { tag.length === 2 && ['enabled', 'disabled'].includes(tag[1]), ); + const normalizedLimit = limit > 0 && limit <= 50 ? limit : 50; const features = await this.featureSearchService.search({ query, projectId, @@ -91,6 +100,8 @@ export default class FeatureSearchController extends Controller { userId, tag: normalizedTag, status: normalizedStatus, + cursor, + limit: normalizedLimit, }); res.json({ features }); } else { 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 3bd904e109..386e498162 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,22 @@ const searchFeatures = async ( .expect(expectedCode); }; +const searchFeaturesWithCursor = async ( + { + query = '', + projectId = 'default', + cursor = '', + limit = 10, + }: FeatureSearchQueryParameters, + expectedCode = 200, +) => { + return app.request + .get( + `/api/admin/search/features?query=${query}&projectId=${projectId}&cursor=${cursor}&limit=${limit}`, + ) + .expect(expectedCode); +}; + const filterFeaturesByType = async (types: string[], expectedCode = 200) => { const typeParams = types.map((type) => `type[]=${type}`).join('&'); return app.request @@ -85,6 +101,46 @@ test('should search matching features by name', async () => { }); }); +test('should paginate with cursor', async () => { + await app.createFeature('my_feature_a'); + await app.createFeature('my_feature_b'); + await app.createFeature('my_feature_c'); + await app.createFeature('my_feature_d'); + + const { body: firstPage } = await searchFeaturesWithCursor({ + query: 'feature', + cursor: '', + limit: 2, + }); + const nextCursor = + firstPage.features[firstPage.features.length - 1].createdAt; + + expect(firstPage).toMatchObject({ + features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }], + }); + + const { body: secondPage } = await searchFeaturesWithCursor({ + query: 'feature', + cursor: nextCursor, + limit: 2, + }); + + expect(secondPage).toMatchObject({ + features: [{ name: 'my_feature_c' }, { name: 'my_feature_d' }], + }); + const lastCursor = + secondPage.features[secondPage.features.length - 1].createdAt; + + const { body: lastPage } = await searchFeaturesWithCursor({ + query: 'feature', + cursor: lastCursor, + limit: 2, + }); + expect(lastPage).toMatchObject({ + features: [], + }); +}); + 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' }); 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 2b015528a6..978a96741b 100644 --- a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts @@ -26,6 +26,7 @@ import { IFeatureProjectUserParams } from './feature-toggle-controller'; import { Db } from '../../db/db'; import Raw = Knex.Raw; import { IFeatureSearchParams } from './types/feature-toggle-strategies-store-type'; +import { addMilliseconds, format, formatISO, parseISO } from 'date-fns'; const COLUMNS = [ 'id', @@ -523,6 +524,8 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { type, tag, status, + cursor, + limit, }: IFeatureSearchParams): Promise { let query = this.db('features'); if (projectId) { @@ -540,9 +543,11 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { `%${queryString}%`, ]); - query = query - .whereILike('features.name', `%${queryString}%`) - .orWhereIn('features.name', tagQuery); + query = query.where((builder) => { + builder + .whereILike('features.name', `%${queryString}%`) + .orWhereIn('features.name', tagQuery); + }); } if (tag && tag.length > 0) { const tagQuery = this.db @@ -571,6 +576,21 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { }); } + // workaround for imprecise timestamp that was including the cursor itself + const addMillisecond = (cursor: string) => + format( + addMilliseconds(parseISO(cursor), 1), + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", + ); + if (cursor) { + query = query.where( + 'features.created_at', + '>', + addMillisecond(cursor), + ); + } + query = query.orderBy('features.created_at', 'asc').limit(limit); + query = query .modify(FeatureToggleStore.filterByArchived, false) .leftJoin( @@ -656,6 +676,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { query = query.select(selectColumns); const rows = await query; + if (rows.length > 0) { const overview = this.getFeatureOverviewData(getUniqueRows(rows)); return sortEnvironments(overview); 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 575203857a..7b358ac87b 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 @@ -28,6 +28,8 @@ export interface IFeatureSearchParams { type?: string[]; tag?: string[][]; status?: string[][]; + limit: number; + cursor?: 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 2c05c89db4..8c4c70ef24 100644 --- a/src/lib/openapi/spec/feature-search-query-parameters.ts +++ b/src/lib/openapi/spec/feature-search-query-parameters.ts @@ -57,6 +57,26 @@ export const featureSearchQueryParameters = [ 'The list of feature environment status to filter by. Feature environment has to specify a name and a status joined with a colon.', in: 'query', }, + { + name: 'cursor', + schema: { + type: 'string', + example: '1', + }, + description: + 'The last feature id the client has seen. Used for cursor-based pagination.', + in: 'query', + }, + { + name: 'limit', + schema: { + type: 'number', + example: 10, + }, + description: + 'The number of results to return in a page. By default it is set to 50', + in: 'query', + }, ] as const; export type FeatureSearchQueryParameters = Partial<