diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/populateCurrentStage.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/populateCurrentStage.ts
index 6798f9143337..d62dfd7ccb0e 100644
--- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/populateCurrentStage.ts
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureLifecycle/populateCurrentStage.ts
@@ -43,7 +43,10 @@ export const populateCurrentStage = (
case 'completed':
return {
name: 'completed',
- status: 'kept',
+ status:
+ feature.lifecycle.status === 'discarded'
+ ? 'discarded'
+ : 'kept',
environments: getFilteredEnvironments(() => true),
enteredStageAt,
};
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx
index c4904320ac71..7d28eedf1901 100644
--- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx
@@ -137,7 +137,10 @@ const FeatureOverviewMetaData = () => {
{project}
Lifecycle:
diff --git a/frontend/src/interfaces/featureToggle.ts b/frontend/src/interfaces/featureToggle.ts
index 4d5edd72c05e..5d3d3d3821e4 100644
--- a/frontend/src/interfaces/featureToggle.ts
+++ b/frontend/src/interfaces/featureToggle.ts
@@ -34,6 +34,7 @@ export type ILastSeenEnvironments = Pick<
export type Lifecycle = {
stage: 'initial' | 'pre-live' | 'live' | 'completed' | 'archived';
+ status?: string;
enteredStageAt: string;
};
diff --git a/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts
index 74ff64cf6ae2..d15b1f9f4923 100644
--- a/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts
+++ b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts
@@ -27,6 +27,9 @@ export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore {
...existingStages,
{
stage: featureLifecycleStage.stage,
+ ...(featureLifecycleStage.status
+ ? { status: featureLifecycleStage.status }
+ : {}),
enteredStageAt: new Date(),
},
];
diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts
index 1f6e5deee8cc..a0dff07d00c9 100644
--- a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts
+++ b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts
@@ -6,6 +6,7 @@ import type { IFeatureLifecycleStage, StageName } from '../../types';
type DBType = {
feature: string;
stage: StageName;
+ status: string | null;
created_at: Date;
};
@@ -23,8 +24,9 @@ export class FeatureLifecycleReadModel implements IFeatureLifecycleReadModel {
.where({ feature })
.orderBy('created_at', 'asc');
- const stages = results.map(({ stage, created_at }: DBType) => ({
+ const stages = results.map(({ stage, status, created_at }: DBType) => ({
stage,
+ ...(status ? { status } : {}),
enteredStageAt: created_at,
}));
diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts
index 925e47af08aa..89076e7f3088 100644
--- a/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts
+++ b/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts
@@ -10,8 +10,8 @@ import type { StageName } from '../../types';
type DBType = {
stage: StageName;
created_at: string;
- status?: string;
- status_value?: string;
+ status: string | null;
+ status_value: string | null;
};
type DBProjectType = DBType & {
@@ -64,8 +64,9 @@ export class FeatureLifecycleStore implements IFeatureLifecycleStore {
.where({ feature })
.orderBy('created_at', 'asc');
- return results.map(({ stage, created_at }: DBType) => ({
+ return results.map(({ stage, status, created_at }: DBType) => ({
stage,
+ ...(status ? { status } : {}),
enteredStageAt: new Date(created_at),
}));
}
diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts b/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts
index ec05cbb0ed89..65e207958a2a 100644
--- a/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts
+++ b/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts
@@ -18,12 +18,15 @@ import {
STAGE_ENTERED,
} from './feature-lifecycle-service';
import type { FeatureLifecycleCompletedSchema } from '../../openapi';
+import { FeatureLifecycleReadModel } from './feature-lifecycle-read-model';
+import type { IFeatureLifecycleReadModel } from './feature-lifecycle-read-model-type';
let app: IUnleashTest;
let db: ITestDb;
let featureLifecycleService: FeatureLifecycleService;
let eventStore: IEventStore;
let eventBus: EventEmitter;
+let featureLifecycleReadModel: IFeatureLifecycleReadModel;
beforeAll(async () => {
db = await dbInit('feature_lifecycle', getLogger);
@@ -41,6 +44,7 @@ beforeAll(async () => {
eventStore = db.stores.eventStore;
eventBus = app.config.eventBus;
featureLifecycleService = app.services.featureLifecycleService;
+ featureLifecycleReadModel = new FeatureLifecycleReadModel(db.rawDatabase);
await app.request
.post(`/auth/demo/login`)
@@ -62,6 +66,11 @@ const getFeatureLifecycle = async (featureName: string, expectedCode = 200) => {
.get(`/api/admin/projects/default/features/${featureName}/lifecycle`)
.expect(expectedCode);
};
+
+const getCurrentStage = async (featureName: string) => {
+ return featureLifecycleReadModel.findCurrentStage(featureName);
+};
+
const completeFeature = async (
featureName: string,
status: FeatureLifecycleCompletedSchema,
@@ -166,6 +175,8 @@ test('should be able to toggle between completed/uncompleted', async () => {
status: 'kept',
statusValue: 'variant1',
});
+ const currentStage = await getCurrentStage('my_feature_b');
+ expect(currentStage).toMatchObject({ stage: 'completed', status: 'kept' });
await expectFeatureStage('my_feature_b', 'completed');
diff --git a/src/lib/features/feature-lifecycle/get-current-stage.test.ts b/src/lib/features/feature-lifecycle/get-current-stage.test.ts
index d4e9646448f1..31bc61154a17 100644
--- a/src/lib/features/feature-lifecycle/get-current-stage.test.ts
+++ b/src/lib/features/feature-lifecycle/get-current-stage.test.ts
@@ -12,6 +12,7 @@ describe('getCurrentStage', () => {
},
{
stage: 'completed',
+ status: 'kept',
enteredStageAt: irrelevantDate,
},
{
diff --git a/src/lib/features/feature-search/feature-search-store.ts b/src/lib/features/feature-search/feature-search-store.ts
index 40cffd7a3f4d..2b7aa9a955b9 100644
--- a/src/lib/features/feature-search/feature-search-store.ts
+++ b/src/lib/features/feature-search/feature-search-store.ts
@@ -79,6 +79,7 @@ class FeatureSearchStore implements IFeatureSearchStore {
.select(
'feature as stage_feature',
'stage as latest_stage',
+ 'status as stage_status',
'created_at as entered_stage_at',
)
.distinctOn('stage_feature')
@@ -416,6 +417,9 @@ class FeatureSearchStore implements IFeatureSearchStore {
entry.lifecycle = row.latest_stage
? {
stage: row.latest_stage,
+ ...(row.stage_status
+ ? { status: row.stage_status }
+ : {}),
enteredStageAt: row.entered_stage_at,
}
: undefined;
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 c303ce06093f..33d78ba3fc2b 100644
--- a/src/lib/features/feature-search/feature.search.e2e.test.ts
+++ b/src/lib/features/feature-search/feature.search.e2e.test.ts
@@ -962,7 +962,7 @@ test('should return environment usage metrics and lifecycle', async () => {
{ feature: 'my_feature_b', stage: 'initial' },
]);
await stores.featureLifecycleStore.insert([
- { feature: 'my_feature_b', stage: 'pre-live' },
+ { feature: 'my_feature_b', stage: 'completed', status: 'discarded' },
]);
const { body } = await searchFeatures({
@@ -972,7 +972,7 @@ test('should return environment usage metrics and lifecycle', async () => {
features: [
{
name: 'my_feature_b',
- lifecycle: { stage: 'pre-live' },
+ lifecycle: { stage: 'completed', status: 'discarded' },
environments: [
{
name: 'default',
diff --git a/src/lib/openapi/spec/feature-lifecycle-schema.ts b/src/lib/openapi/spec/feature-lifecycle-schema.ts
index 7586fc04e9c8..88878f8a75ea 100644
--- a/src/lib/openapi/spec/feature-lifecycle-schema.ts
+++ b/src/lib/openapi/spec/feature-lifecycle-schema.ts
@@ -16,6 +16,12 @@ export const featureLifecycleSchema = {
description:
'The name of the lifecycle stage that got recorded for a given feature',
},
+ status: {
+ type: 'string',
+ example: 'kept',
+ description:
+ 'The name of the detailed status of a given stage. E.g. completed stage can be kept or discarded.',
+ },
enteredStageAt: {
type: 'string',
format: 'date-time',
diff --git a/src/lib/openapi/spec/feature-search-response-schema.ts b/src/lib/openapi/spec/feature-search-response-schema.ts
index b517e331b9d4..8c125a6411a4 100644
--- a/src/lib/openapi/spec/feature-search-response-schema.ts
+++ b/src/lib/openapi/spec/feature-search-response-schema.ts
@@ -161,6 +161,13 @@ export const featureSearchResponseSchema = {
],
example: 'initial',
},
+ status: {
+ type: 'string',
+ nullable: true,
+ example: 'kept',
+ description:
+ 'The name of the detailed status of a given stage. E.g. completed stage can be kept or discarded.',
+ },
enteredStageAt: {
description: 'When the feature entered this stage',
type: 'string',
diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts
index 7d0884544734..7fcf38e63674 100644
--- a/src/lib/types/model.ts
+++ b/src/lib/types/model.ts
@@ -163,6 +163,7 @@ export type StageName =
export interface IFeatureLifecycleStage {
stage: StageName;
enteredStageAt: Date;
+ status?: string;
}
export type IProjectLifecycleStageDuration = {