mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-25 00:07:47 +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