diff --git a/src/lib/features/feature-search/feature-search-service.ts b/src/lib/features/feature-search/feature-search-service.ts index a07d860b0d..10e45b28f0 100644 --- a/src/lib/features/feature-search/feature-search-service.ts +++ b/src/lib/features/feature-search/feature-search-service.ts @@ -45,9 +45,6 @@ export class FeatureSearchService { if (params.state) { const parsedState = parseSearchOperatorValue('stale', params.state); if (parsedState) { - parsedState.values = parsedState.values.map((value) => - value === 'active' ? 'false' : 'true', - ); queryParams.push(parsedState); } } diff --git a/src/lib/features/feature-search/feature-search-store.ts b/src/lib/features/feature-search/feature-search-store.ts index f08eb624f8..b70c33599a 100644 --- a/src/lib/features/feature-search/feature-search-store.ts +++ b/src/lib/features/feature-search/feature-search-store.ts @@ -569,19 +569,119 @@ class FeatureSearchStore implements IFeatureSearchStore { } } +const applyStaleConditions = ( + query: Knex.QueryBuilder, + staleConditions?: IQueryParam, +): void => { + if (!staleConditions) return; + + const { values, operator } = staleConditions; + + if (!values.includes('potentiallyStale')) { + applyGenericQueryParams(query, [ + { + ...staleConditions, + values: values.map((value) => + value === 'active' ? 'false' : 'true', + ), + }, + ]); + return; + } + + const valueSet = new Set( + values.filter((value) => + ['stale', 'active', 'potentiallyStale'].includes(value || ''), + ), + ); + const allSelected = valueSet.size === 3; + const onlyPotentiallyStale = valueSet.size === 1; + const staleAndPotentiallyStale = + valueSet.has('stale') && valueSet.size === 2; + + if (allSelected) { + switch (operator) { + case 'IS': + case 'IS_ANY_OF': + // All flags included; no action needed + break; + case 'IS_NOT': + case 'IS_NONE_OF': + // All flags excluded + query.whereNotIn('features.stale', [false, true]); + break; + } + return; + } + + if (onlyPotentiallyStale) { + switch (operator) { + case 'IS': + case 'IS_ANY_OF': + query + .where('features.stale', false) + .where('features.potentially_stale', true); + break; + case 'IS_NOT': + case 'IS_NONE_OF': + query.where((qb) => + qb + .where('features.stale', true) + .orWhere('features.potentially_stale', false), + ); + break; + } + return; + } + + if (staleAndPotentiallyStale) { + switch (operator) { + case 'IS': + case 'IS_ANY_OF': + query.where((qb) => + qb + .where('features.stale', true) + .orWhere('features.potentially_stale', true), + ); + break; + case 'IS_NOT': + case 'IS_NONE_OF': + query + .where('features.stale', false) + .where('features.potentially_stale', false); + break; + } + } else { + switch (operator) { + case 'IS': + case 'IS_ANY_OF': + query.where('features.stale', false); + break; + case 'IS_NOT': + case 'IS_NONE_OF': + query.where('features.stale', true); + break; + } + } +}; const applyQueryParams = ( query: Knex.QueryBuilder, queryParams: IQueryParam[], ): void => { const tagConditions = queryParams.filter((param) => param.field === 'tag'); + const staleConditions = queryParams.find( + (param) => param.field === 'stale', + ); const segmentConditions = queryParams.filter( (param) => param.field === 'segment', ); const genericConditions = queryParams.filter( - (param) => param.field !== 'tag', + (param) => !['tag', 'stale'].includes(param.field), ); applyGenericQueryParams(query, genericConditions); + applyStaleConditions(query, staleConditions); + applyMultiQueryParams( query, tagConditions, 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 41d52ad589..ec46c6c3bd 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -968,27 +968,74 @@ test('should search features by state with operators', async () => { }); }); -test('should search features by created date with operators', async () => { +test('should search features by potentially stale', async () => { await app.createFeature({ name: 'my_feature_a', - createdAt: '2023-01-27T15:21:39.975Z', + stale: false, }); await app.createFeature({ name: 'my_feature_b', - createdAt: '2023-01-29T15:21:39.975Z', + stale: true, + }); + await app.createFeature({ + name: 'my_feature_c', + stale: false, + }); + await app.createFeature({ + name: 'my_feature_d', + stale: true, }); - const { body } = await filterFeaturesByCreated('IS_BEFORE:2023-01-28'); - expect(body).toMatchObject({ - features: [{ name: 'my_feature_a' }], - }); + // this is all done on a schedule, so there's no imperative way to mark something as potentially stale today. + await db + .rawDatabase('features') + .update('potentially_stale', true) + .whereIn('name', ['my_feature_c', 'my_feature_d']); - const { body: afterBody } = await filterFeaturesByCreated( - 'IS_ON_OR_AFTER:2023-01-28', - ); - expect(afterBody).toMatchObject({ - features: [{ name: 'my_feature_b' }], - }); + const check = async (filter: string, expectedFlags: string[]) => { + const { body } = await filterFeaturesByState(filter); + expect(body).toMatchObject({ + features: expectedFlags.map((flag) => ({ name: flag })), + }); + }; + + // single filters work + await check('IS:potentiallyStale', ['my_feature_c']); + // (stale or !potentiallyStale) + await check('IS_NOT:potentiallyStale', [ + 'my_feature_a', + 'my_feature_b', + 'my_feature_d', + ]); + + // combo filters work + await check('IS_ANY_OF:active,potentiallyStale', [ + 'my_feature_a', + 'my_feature_c', + ]); + + // (potentiallyStale OR stale) + await check('IS_ANY_OF:potentiallyStale,stale', [ + 'my_feature_b', + 'my_feature_c', + 'my_feature_d', + ]); + + await check('IS_ANY_OF:active,potentiallyStale,stale', [ + 'my_feature_a', + 'my_feature_b', + 'my_feature_c', + 'my_feature_d', + ]); + + await check('IS_NONE_OF:active,potentiallyStale,stale', []); + + await check('IS_NONE_OF:active,potentiallyStale', [ + 'my_feature_b', + 'my_feature_d', + ]); + + await check('IS_NONE_OF:potentiallyStale,stale', ['my_feature_a']); }); test('should filter features by combined operators', async () => { diff --git a/src/lib/features/feature-search/search-utils.ts b/src/lib/features/feature-search/search-utils.ts index a9dbe49d0a..98ce277008 100644 --- a/src/lib/features/feature-search/search-utils.ts +++ b/src/lib/features/feature-search/search-utils.ts @@ -116,7 +116,7 @@ export const parseSearchOperatorValue = ( return { field, operator: match[1] as IQueryOperator, - values: match[2].split(','), + values: match[2].split(',').map((value) => value.trim()), }; }