mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: expose lifecycle stage in project overview search (#7017)
This commit is contained in:
		
							parent
							
								
									28a7797aea
								
							
						
					
					
						commit
						97d702afeb
					
				| @ -44,6 +44,7 @@ import { InactiveUsersStore } from '../users/inactive/inactive-users-store'; | ||||
| import { TrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store'; | ||||
| import { SegmentReadModel } from '../features/segment/segment-read-model'; | ||||
| import { ProjectOwnersReadModel } from '../features/project/project-owners-read-model'; | ||||
| import { FeatureLifecycleStore } from '../features/feature-lifecycle/feature-lifecycle-store'; | ||||
| 
 | ||||
| export const createStores = ( | ||||
|     config: IUnleashConfig, | ||||
| @ -145,11 +146,17 @@ export const createStores = ( | ||||
|         privateProjectStore: new PrivateProjectStore(db, getLogger), | ||||
|         dependentFeaturesStore: new DependentFeaturesStore(db), | ||||
|         lastSeenStore: new LastSeenStore(db, eventBus, getLogger), | ||||
|         featureSearchStore: new FeatureSearchStore(db, eventBus, getLogger), | ||||
|         featureSearchStore: new FeatureSearchStore( | ||||
|             db, | ||||
|             eventBus, | ||||
|             getLogger, | ||||
|             config.flagResolver, | ||||
|         ), | ||||
|         inactiveUsersStore: new InactiveUsersStore(db, eventBus, getLogger), | ||||
|         trafficDataUsageStore: new TrafficDataUsageStore(db, getLogger), | ||||
|         segmentReadModel: new SegmentReadModel(db), | ||||
|         projectOwnersReadModel: new ProjectOwnersReadModel(db), | ||||
|         featureLifecycleStore: new FeatureLifecycleStore(db), | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -13,6 +13,7 @@ export const createFeatureSearchService = | ||||
|             db, | ||||
|             eventBus, | ||||
|             getLogger, | ||||
|             flagResolver, | ||||
|         ); | ||||
| 
 | ||||
|         return new FeatureSearchService( | ||||
|  | ||||
| @ -7,6 +7,7 @@ import type { | ||||
|     IFeatureOverview, | ||||
|     IFeatureSearchOverview, | ||||
|     IFeatureSearchStore, | ||||
|     IFlagResolver, | ||||
|     ITag, | ||||
| } from '../../types'; | ||||
| import FeatureToggleStore from '../feature-toggle/feature-toggle-store'; | ||||
| @ -40,9 +41,17 @@ class FeatureSearchStore implements IFeatureSearchStore { | ||||
| 
 | ||||
|     private readonly timer: Function; | ||||
| 
 | ||||
|     constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) { | ||||
|     private flagResolver: IFlagResolver; | ||||
| 
 | ||||
|     constructor( | ||||
|         db: Db, | ||||
|         eventBus: EventEmitter, | ||||
|         getLogger: LogProvider, | ||||
|         flagResolver: IFlagResolver, | ||||
|     ) { | ||||
|         this.db = db; | ||||
|         this.logger = getLogger('feature-search-store.ts'); | ||||
|         this.flagResolver = flagResolver; | ||||
|         this.timer = (action) => | ||||
|             metricsHelper.wrapTimer(eventBus, DB_TIME, { | ||||
|                 store: 'feature-search', | ||||
| @ -65,6 +74,20 @@ class FeatureSearchStore implements IFeatureSearchStore { | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     private getLatestLifecycleStageQuery() { | ||||
|         return this.db('feature_lifecycles') | ||||
|             .select( | ||||
|                 'feature as stage_feature', | ||||
|                 'stage as latest_stage', | ||||
|                 'created_at as entered_stage_at', | ||||
|             ) | ||||
|             .distinctOn('stage_feature') | ||||
|             .orderBy([ | ||||
|                 'stage_feature', | ||||
|                 { column: 'entered_stage_at', order: 'desc' }, | ||||
|             ]); | ||||
|     } | ||||
| 
 | ||||
|     async searchFeatures( | ||||
|         { | ||||
|             userId, | ||||
| @ -86,6 +109,9 @@ class FeatureSearchStore implements IFeatureSearchStore { | ||||
|         const validatedSortOrder = | ||||
|             sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc'; | ||||
| 
 | ||||
|         const featureLifecycleEnabled = | ||||
|             this.flagResolver.isEnabled('featureLifecycle'); | ||||
| 
 | ||||
|         const finalQuery = this.db | ||||
|             .with('ranked_features', (query) => { | ||||
|                 query.from('features'); | ||||
| @ -235,6 +261,7 @@ class FeatureSearchStore implements IFeatureSearchStore { | ||||
|                     .select(selectColumns) | ||||
|                     .denseRank('rank', this.db.raw(rankingSql)); | ||||
|             }) | ||||
|             .with('lifecycle', this.getLatestLifecycleStageQuery()) | ||||
|             .with( | ||||
|                 'final_ranks', | ||||
|                 this.db.raw( | ||||
| @ -290,10 +317,20 @@ class FeatureSearchStore implements IFeatureSearchStore { | ||||
|             .joinRaw('CROSS JOIN total_features') | ||||
|             .whereBetween('final_rank', [offset + 1, offset + limit]) | ||||
|             .orderBy('final_rank'); | ||||
|         if (featureLifecycleEnabled) { | ||||
|             finalQuery.leftJoin( | ||||
|                 'lifecycle', | ||||
|                 'ranked_features.feature_name', | ||||
|                 'lifecycle.stage_feature', | ||||
|             ); | ||||
|         } | ||||
|         const rows = await finalQuery; | ||||
|         stopTimer(); | ||||
|         if (rows.length > 0) { | ||||
|             const overview = this.getAggregatedSearchData(rows); | ||||
|             const overview = this.getAggregatedSearchData( | ||||
|                 rows, | ||||
|                 featureLifecycleEnabled, | ||||
|             ); | ||||
|             const features = sortEnvironments(overview); | ||||
|             return { | ||||
|                 features, | ||||
| @ -349,7 +386,10 @@ class FeatureSearchStore implements IFeatureSearchStore { | ||||
|         return rankingSql; | ||||
|     } | ||||
| 
 | ||||
|     getAggregatedSearchData(rows): IFeatureSearchOverview[] { | ||||
|     getAggregatedSearchData( | ||||
|         rows, | ||||
|         featureLifecycleEnabled: boolean, | ||||
|     ): IFeatureSearchOverview[] { | ||||
|         const entriesMap: Map<string, IFeatureSearchOverview> = new Map(); | ||||
|         const orderedEntries: IFeatureSearchOverview[] = []; | ||||
| 
 | ||||
| @ -372,6 +412,14 @@ class FeatureSearchStore implements IFeatureSearchStore { | ||||
|                     environments: [], | ||||
|                     segments: row.segment_name ? [row.segment_name] : [], | ||||
|                 }; | ||||
|                 if (featureLifecycleEnabled) { | ||||
|                     entry.lifecycle = row.latest_stage | ||||
|                         ? { | ||||
|                               stage: row.latest_stage, | ||||
|                               enteredStageAt: row.entered_stage_at, | ||||
|                           } | ||||
|                         : undefined; | ||||
|                 } | ||||
|                 entriesMap.set(row.feature_name, entry); | ||||
|                 orderedEntries.push(entry); | ||||
|             } | ||||
|  | ||||
| @ -21,6 +21,7 @@ beforeAll(async () => { | ||||
|             experimental: { | ||||
|                 flags: { | ||||
|                     strictSchemaValidation: true, | ||||
|                     featureLifecycle: true, | ||||
|                 }, | ||||
|             }, | ||||
|         }, | ||||
| @ -924,7 +925,7 @@ test('should filter features by combined operators', async () => { | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| test('should return environment usage metrics', async () => { | ||||
| test('should return environment usage metrics and lifecycle', async () => { | ||||
|     await app.createFeature({ | ||||
|         name: 'my_feature_b', | ||||
|         createdAt: '2023-01-29T15:21:39.975Z', | ||||
| @ -957,6 +958,13 @@ test('should return environment usage metrics', async () => { | ||||
|         }, | ||||
|     ]); | ||||
| 
 | ||||
|     await stores.featureLifecycleStore.insert([ | ||||
|         { feature: 'my_feature_b', stage: 'initial' }, | ||||
|     ]); | ||||
|     await stores.featureLifecycleStore.insert([ | ||||
|         { feature: 'my_feature_b', stage: 'pre-live' }, | ||||
|     ]); | ||||
| 
 | ||||
|     const { body } = await searchFeatures({ | ||||
|         query: 'my_feature_b', | ||||
|     }); | ||||
| @ -964,6 +972,7 @@ test('should return environment usage metrics', async () => { | ||||
|         features: [ | ||||
|             { | ||||
|                 name: 'my_feature_b', | ||||
|                 lifecycle: { stage: 'pre-live' }, | ||||
|                 environments: [ | ||||
|                     { | ||||
|                         name: 'default', | ||||
|  | ||||
| @ -143,6 +143,32 @@ export const featureSearchResponseSchema = { | ||||
|             nullable: true, | ||||
|             description: 'The list of feature tags', | ||||
|         }, | ||||
|         lifecycle: { | ||||
|             type: 'object', | ||||
|             description: 'Current lifecycle stage of the feature', | ||||
|             additionalProperties: false, | ||||
|             required: ['stage', 'enteredStageAt'], | ||||
|             properties: { | ||||
|                 stage: { | ||||
|                     description: 'The name of the current lifecycle stage', | ||||
|                     type: 'string', | ||||
|                     enum: [ | ||||
|                         'initial', | ||||
|                         'pre-live', | ||||
|                         'live', | ||||
|                         'completed', | ||||
|                         'archived', | ||||
|                     ], | ||||
|                     example: 'initial', | ||||
|                 }, | ||||
|                 enteredStageAt: { | ||||
|                     description: 'When the feature entered this stage', | ||||
|                     type: 'string', | ||||
|                     format: 'date-time', | ||||
|                     example: '2023-01-28T15:21:39.975Z', | ||||
|                 }, | ||||
|             }, | ||||
|         }, | ||||
|     }, | ||||
|     components: { | ||||
|         schemas: { | ||||
|  | ||||
| @ -237,6 +237,7 @@ export interface IFeatureOverview { | ||||
|     createdAt: Date; | ||||
|     lastSeenAt: Date; | ||||
|     environments: IEnvironmentOverview[]; | ||||
|     lifecycle?: IFeatureLifecycleStage; | ||||
| } | ||||
| 
 | ||||
| export type IFeatureSearchOverview = Exclude< | ||||
|  | ||||
| @ -41,6 +41,7 @@ import type { IInactiveUsersStore } from '../users/inactive/types/inactive-users | ||||
| import { ITrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store-type'; | ||||
| import { ISegmentReadModel } from '../features/segment/segment-read-model-type'; | ||||
| import { IProjectOwnersReadModel } from '../features/project/project-owners-read-model.type'; | ||||
| import { IFeatureLifecycleStore } from '../features/feature-lifecycle/feature-lifecycle-store-type'; | ||||
| 
 | ||||
| export interface IUnleashStores { | ||||
|     accessStore: IAccessStore; | ||||
| @ -86,6 +87,7 @@ export interface IUnleashStores { | ||||
|     trafficDataUsageStore: ITrafficDataUsageStore; | ||||
|     segmentReadModel: ISegmentReadModel; | ||||
|     projectOwnersReadModel: IProjectOwnersReadModel; | ||||
|     featureLifecycleStore: IFeatureLifecycleStore; | ||||
| } | ||||
| 
 | ||||
| export { | ||||
| @ -130,4 +132,5 @@ export { | ||||
|     ITrafficDataUsageStore, | ||||
|     ISegmentReadModel, | ||||
|     IProjectOwnersReadModel, | ||||
|     IFeatureLifecycleStore, | ||||
| }; | ||||
|  | ||||
							
								
								
									
										2
									
								
								src/test/fixtures/store.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								src/test/fixtures/store.ts
									
									
									
									
										vendored
									
									
								
							| @ -44,6 +44,7 @@ import { FakeInactiveUsersStore } from '../../lib/users/inactive/fakes/fake-inac | ||||
| import { FakeTrafficDataUsageStore } from '../../lib/features/traffic-data-usage/fake-traffic-data-usage-store'; | ||||
| import { FakeSegmentReadModel } from '../../lib/features/segment/fake-segment-read-model'; | ||||
| import { FakeProjectOwnersReadModel } from '../../lib/features/project/fake-project-owners-read-model'; | ||||
| import { FakeFeatureLifecycleStore } from '../../lib/features/feature-lifecycle/fake-feature-lifecycle-store'; | ||||
| 
 | ||||
| const db = { | ||||
|     select: () => ({ | ||||
| @ -97,6 +98,7 @@ const createStores: () => IUnleashStores = () => { | ||||
|         trafficDataUsageStore: new FakeTrafficDataUsageStore(), | ||||
|         segmentReadModel: new FakeSegmentReadModel(), | ||||
|         projectOwnersReadModel: new FakeProjectOwnersReadModel(), | ||||
|         featureLifecycleStore: new FakeFeatureLifecycleStore(), | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user