From 7d7a9490934e2db2904c09969da4762e9754806f Mon Sep 17 00:00:00 2001 From: Fredrik Strand Oseberg Date: Tue, 25 Mar 2025 14:45:44 +0100 Subject: [PATCH] feat: backend for retrieving tag colors (#9610) Add backend for retrieving tag colors --- src/lib/db/feature-tag-store.ts | 11 +++- .../feature-search/feature-search-store.ts | 3 + .../feature-search/feature.search.e2e.test.ts | 34 +++++++++++ .../deprecated-project-overview-schema.ts | 2 + .../openapi/spec/health-overview-schema.ts | 2 + src/lib/openapi/spec/tag-schema.ts | 7 +++ src/lib/types/model.ts | 1 + src/test/e2e/api/admin/tags.e2e.test.ts | 58 +++++++++++++++++-- .../e2e/stores/feature-tag-store.e2e.test.ts | 3 +- 9 files changed, 113 insertions(+), 8 deletions(-) diff --git a/src/lib/db/feature-tag-store.ts b/src/lib/db/feature-tag-store.ts index 6de21dcff7..f2acbbbeca 100644 --- a/src/lib/db/feature-tag-store.ts +++ b/src/lib/db/feature-tag-store.ts @@ -102,11 +102,18 @@ class FeatureTagStore implements IFeatureTagStore { const stopTimer = this.timer('getAllForFeature'); if (await this.featureExists(featureName)) { const rows = await this.db - .select(COLUMNS) + .select([...COLUMNS, 'tag_types.color as color']) .from(TABLE) + .leftJoin('tag_types', 'tag_types.name', 'feature_tag.tag_type') .where({ feature_name: featureName }); + stopTimer(); - return rows.map(this.featureTagRowToTag); + + return rows.map((row) => ({ + type: row.tag_type, + value: row.tag_value, + color: row.color, + })); } else { throw new NotFoundError( `Could not find feature with name ${featureName}`, diff --git a/src/lib/features/feature-search/feature-search-store.ts b/src/lib/features/feature-search/feature-search-store.ts index 0c54e51255..c27baa7d78 100644 --- a/src/lib/features/feature-search/feature-search-store.ts +++ b/src/lib/features/feature-search/feature-search-store.ts @@ -134,6 +134,7 @@ class FeatureSearchStore implements IFeatureSearchStore { 'environments.sort_order as environment_sort_order', 'ft.tag_value as tag_value', 'ft.tag_type as tag_type', + 'tag_types.color as tag_type_color', 'segments.name as segment_name', 'users.id as user_id', 'users.name as user_name', @@ -207,6 +208,7 @@ class FeatureSearchStore implements IFeatureSearchStore { 'ft.feature_name', 'features.name', ) + .leftJoin('tag_types', 'tag_types.name', 'ft.tag_type') .leftJoin( 'feature_strategies', 'feature_strategies.feature_name', @@ -548,6 +550,7 @@ class FeatureSearchStore implements IFeatureSearchStore { return { value: r.tag_value, type: r.tag_type, + color: r.tag_type_color, }; } 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 5038409c0f..976c826c90 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -1252,3 +1252,37 @@ test('should return archived when query param set', async () => { ], }); }); + +test('should return tags with color information from tag type', async () => { + await app.createFeature('my_feature_a'); + + await app.request + .put('/api/admin/tag-types/simple') + .send({ + name: 'simple', + color: '#FF0000', + }) + .expect(200); + + await app.addTag('my_feature_a', { + type: 'simple', + value: 'my_tag', + }); + + const { body } = await searchFeatures({}); + + expect(body).toMatchObject({ + features: [ + { + name: 'my_feature_a', + tags: [ + { + type: 'simple', + value: 'my_tag', + color: '#FF0000', + }, + ], + }, + ], + }); +}); diff --git a/src/lib/openapi/spec/deprecated-project-overview-schema.ts b/src/lib/openapi/spec/deprecated-project-overview-schema.ts index f1e1c52e16..6bc150a291 100644 --- a/src/lib/openapi/spec/deprecated-project-overview-schema.ts +++ b/src/lib/openapi/spec/deprecated-project-overview-schema.ts @@ -13,6 +13,7 @@ import { projectEnvironmentSchema } from './project-environment-schema'; import { createStrategyVariantSchema } from './create-strategy-variant-schema'; import { strategyVariantSchema } from './strategy-variant-schema'; import { createFeatureNamingPatternSchema } from './create-feature-naming-pattern-schema'; +import { tagSchema } from './tag-schema'; export const deprecatedProjectOverviewSchema = { $id: '#/components/schemas/deprecatedProjectOverviewSchema', @@ -144,6 +145,7 @@ export const deprecatedProjectOverviewSchema = { variantSchema, projectStatsSchema, createFeatureNamingPatternSchema, + tagSchema, }, }, } as const; diff --git a/src/lib/openapi/spec/health-overview-schema.ts b/src/lib/openapi/spec/health-overview-schema.ts index 5224495e93..37e00743bd 100644 --- a/src/lib/openapi/spec/health-overview-schema.ts +++ b/src/lib/openapi/spec/health-overview-schema.ts @@ -13,6 +13,7 @@ import { projectEnvironmentSchema } from './project-environment-schema'; import { createStrategyVariantSchema } from './create-strategy-variant-schema'; import { strategyVariantSchema } from './strategy-variant-schema'; import { createFeatureNamingPatternSchema } from './create-feature-naming-pattern-schema'; +import { tagSchema } from './tag-schema'; export const healthOverviewSchema = { $id: '#/components/schemas/healthOverviewSchema', @@ -138,6 +139,7 @@ export const healthOverviewSchema = { variantSchema, projectStatsSchema, createFeatureNamingPatternSchema, + tagSchema, }, }, } as const; diff --git a/src/lib/openapi/spec/tag-schema.ts b/src/lib/openapi/spec/tag-schema.ts index e08342f080..f402fb54ab 100644 --- a/src/lib/openapi/spec/tag-schema.ts +++ b/src/lib/openapi/spec/tag-schema.ts @@ -24,6 +24,13 @@ export const tagSchema = { 'The [type](https://docs.getunleash.io/reference/feature-toggles#tags) of the tag', example: 'simple', }, + color: { + type: 'string', + description: 'The hexadecimal color code for the tag type.', + example: '#FFFFFF', + pattern: '^#[0-9A-Fa-f]{6}$', + nullable: true, + }, }, components: {}, } as const; diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 5fc7806d71..82207be1a6 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -355,6 +355,7 @@ export interface IFeatureToggleDeltaQuery extends IFeatureToggleQuery { export interface ITag { value: string; type: string; + color?: string | null; } export interface IAddonParameterDefinition { diff --git a/src/test/e2e/api/admin/tags.e2e.test.ts b/src/test/e2e/api/admin/tags.e2e.test.ts index fb5f08428c..08d318a25d 100644 --- a/src/test/e2e/api/admin/tags.e2e.test.ts +++ b/src/test/e2e/api/admin/tags.e2e.test.ts @@ -10,13 +10,17 @@ let db: ITestDb; beforeAll(async () => { db = await dbInit('tag_api_serial', getLogger); - app = await setupAppWithCustomConfig(db.stores, { - experimental: { - flags: { - strictSchemaValidation: true, + app = await setupAppWithCustomConfig( + db.stores, + { + experimental: { + flags: { + strictSchemaValidation: true, + }, }, }, - }); + db.rawDatabase, + ); }); afterAll(async () => { @@ -219,3 +223,47 @@ test('backward compatibility: the API should return invalid tag names if they ex const { body } = await app.request.get('/api/admin/tags').expect(200); expect(body.tags).toContainEqual(tag); }); + +test('should include tag color information when getting feature tags', async () => { + const featureName = 'test.feature.with.color'; + const tagType = 'simple'; + const tag = { + value: 'TeamRed', + type: tagType, + }; + + await app.request.post('/api/admin/projects/default/features').send({ + name: featureName, + type: 'kill-switch', + enabled: true, + strategies: [{ name: 'default' }], + }); + + await app.request + .put(`/api/admin/tag-types/${tagType}`) + .send({ + name: tagType, + color: '#FF0000', + }) + .expect(200); + + await app.request + .put(`/api/admin/features/${featureName}/tags`) + .send({ addedTags: [tag], removedTags: [] }) + .expect(200); + + const { body } = await app.request + .get(`/api/admin/features/${featureName}/tags`) + .expect('Content-Type', /json/) + .expect(200); + + expect(body).toMatchObject({ + tags: [ + { + value: 'TeamRed', + type: 'simple', + color: '#FF0000', + }, + ], + }); +}); diff --git a/src/test/e2e/stores/feature-tag-store.e2e.test.ts b/src/test/e2e/stores/feature-tag-store.e2e.test.ts index 099ad6ae9d..14527f68df 100644 --- a/src/test/e2e/stores/feature-tag-store.e2e.test.ts +++ b/src/test/e2e/stores/feature-tag-store.e2e.test.ts @@ -13,6 +13,7 @@ let featureToggleStore: IFeatureToggleStore; const featureName = 'test-tag'; const tag = { type: 'simple', value: 'test' }; const TESTUSERID = 3333; +const DEFAULT_TAG_COLOR = '#FFFFFF'; beforeAll(async () => { db = await dbInit('feature_tag_store_serial', getLogger); @@ -45,7 +46,7 @@ test('should tag feature', async () => { createdByUserId: TESTUSERID, }); expect(featureTags).toHaveLength(1); - expect(featureTags[0]).toStrictEqual(tag); + expect(featureTags[0]).toStrictEqual({ ...tag, color: DEFAULT_TAG_COLOR }); expect(featureTag!.featureName).toBe(featureName); expect(featureTag!.tagValue).toBe(tag.value); });