Repository: Unleash/unleash
Pull request #9565: Feat/tag type colors backend
Author: login: FredrikOseberg
Created: 2025-03-18T14:04:50Z | Merged: 2025-03-18T14:27:41Z

# Add Color Support to Tag Types

## Overview
Adds color support to tag types for better visual differentiation in the UI. Each tag type can now have an associated hex color code (e.g., '#FF0000').

## Changes
- Updated interfaces, validation, and store layer to handle color property
- Added comprehensive tests for color validation and operations

## Backward Compatibility
- Color field is optional and nullable
- Existing tag types backfilled with `#FFFFFF`
- API remains compatible with existing clients

## Next Steps
- Update frontend components to display/edit tag colors
- Add color picker UI component
- Update tags with color information

Backend changes are backwards compatible and innocent, so I will add a feature flag when I introduce the frontend changes.



Diff:
diff --git a/src/lib/features/tag-type/tag-type-store-type.ts b/src/lib/features/tag-type/tag-type-store-type.ts
index b52b4c3fe3..0f1e505db4 100644
--- a/src/lib/features/tag-type/tag-type-store-type.ts
+++ b/src/lib/features/tag-type/tag-type-store-type.ts
@@ -4,6 +4,7 @@ export interface ITagType {
     name: string;
     description?: string;
     icon?: string | null;
+    color?: string | null;
 }
 
 export interface ITagTypeStore extends Store<ITagType, string> {
diff --git a/src/lib/features/tag-type/tag-type-store.ts b/src/lib/features/tag-type/tag-type-store.ts
index ba00d14e8c..43bd6eb418 100644
--- a/src/lib/features/tag-type/tag-type-store.ts
+++ b/src/lib/features/tag-type/tag-type-store.ts
@@ -6,13 +6,14 @@ import NotFoundError from '../../error/notfound-error';
 import type { ITagType, ITagTypeStore } from './tag-type-store-type';
 import type { Db } from '../../db/db';
 
-const COLUMNS = ['name', 'description', 'icon'];
+const COLUMNS = ['name', 'description', 'icon', 'color'];
 const TABLE = 'tag_types';
 
 interface ITagTypeTable {
     name: string;
     description?: string;
     icon?: string;
+    color?: string;
 }
 
 export default class TagTypeStore implements ITagTypeStore {
@@ -96,9 +97,16 @@ export default class TagTypeStore implements ITagTypeStore {
         return [];
     }
 
-    async updateTagType({ name, description, icon }: ITagType): Promise<void> {
+    async updateTagType({
+        name,
+        description,
+        icon,
+        color,
+    }: ITagType): Promise<void> {
         const stopTimer = this.timer('updateTagType');
-        await this.db(TABLE).where({ name }).update({ description, icon });
+        await this.db(TABLE)
+            .where({ name })
+            .update({ description, icon, color });
         stopTimer();
     }
 
@@ -109,6 +117,7 @@ export default class TagTypeStore implements ITagTypeStore {
             name: row.name,
             description: row.description,
             icon: row.icon,
+            color: row.color,
         };
     }
 }
diff --git a/src/lib/features/tag-type/tag-type.ts b/src/lib/features/tag-type/tag-type.ts
index 13db0296ad..f6a9fa1d5a 100644
--- a/src/lib/features/tag-type/tag-type.ts
+++ b/src/lib/features/tag-type/tag-type.ts
@@ -212,11 +212,19 @@ class TagTypeController extends Controller {
         req: IAuthRequest<{ name: string }, unknown, UpdateTagTypeSchema>,
         res: Response,
     ): Promise<void> {
-        const { description, icon } = req.body;
+        const { description, icon, color } = req.body;
         const { name } = req.params;
 
         await this.tagTypeService.transactional((service) =>
-            service.updateTagType({ name, description, icon }, req.audit),
+            service.updateTagType(
+                {
+                    name,
+                    description,
+                    icon,
+                    color: color as string | null | undefined,
+                },
+                req.audit,
+            ),
         );
         res.status(200).end();
     }
diff --git a/src/lib/features/tag-type/tag-types.e2e.test.ts b/src/lib/features/tag-type/tag-types.e2e.test.ts
index 35331871fb..9cd8a5c1d8 100644
--- a/src/lib/features/tag-type/tag-types.e2e.test.ts
+++ b/src/lib/features/tag-type/tag-types.e2e.test.ts
@@ -79,6 +79,25 @@ test('Can create a new tag type', async () => {
         });
 });
 
+test('Can create a new tag type with color', async () => {
+    await app.request
+        .post('/api/admin/tag-types')
+        .send({
+            name: 'colored-tag',
+            description: 'A tag type with a color',
+            icon: 'icon',
+            color: '#FF5733',
+        })
+        .expect(201);
+    return app.request
+        .get('/api/admin/tag-types/colored-tag')
+        .expect('Content-Type', /json/)
+        .expect(200)
+        .expect((res) => {
+            expect(res.body.tagType.color).toBe('#FF5733');
+        });
+});
+
 test('Invalid tag types gets rejected', async () => {
     await app.request
         .post('/api/admin/tag-types')
@@ -96,6 +115,20 @@ test('Invalid tag types gets rejected', async () => {
         });
 });
 
+test('Tag type with invalid color format gets rejected', async () => {
+    const res = await app.request
+        .post('/api/admin/tag-types')
+        .send({
+            name: 'invalid-color-tag',
+            description: 'A tag with invalid color',
+            color: 'not-a-color',
+        })
+        .set('Content-Type', 'application/json')
+        .expect(400);
+
+    expect(res.body.details[0].message).toMatch(/color/);
+});
+
 test('Can update a tag types description and icon', async () => {
     await app.request.get('/api/admin/tag-types/simple').expect(200);
     await app.request
@@ -113,6 +146,32 @@ test('Can update a tag types description and icon', async () => {
             expect(res.body.tagType.icon).toBe('$');
         });
 });
+
+test('Can update a tag type color', async () => {
+    await app.request
+        .post('/api/admin/tag-types')
+        .send({
+            name: 'color-update-tag',
+            description: 'A tag type to test color updates',
+            color: '#FFFFFF',
+        })
+        .expect(201);
+
+    await app.request
+        .put('/api/admin/tag-types/color-update-tag')
+        .send({
+            color: '#00FF00',
+        })
+        .expect(200);
+
+    const res = await app.request
+        .get('/api/admin/tag-types/color-update-tag')
+        .expect('Content-Type', /json/)
+        .expect(200);
+
+    expect(res.body.tagType.color).toBe('#00FF00');
+});
+
 test('Numbers are coerced to strings for icons and descriptions', async () => {
     await app.request.get('/api/admin/tag-types/simple').expect(200);
     await app.request
@@ -139,6 +198,34 @@ test('Validation of tag-types returns 200 for valid tag-types', async () => {
         });
 });
 
+test('Validation of tag-types with valid color is successful', async () => {
+    const res = await app.request
+        .post('/api/admin/tag-types/validate')
+        .send({
+            name: 'color-validation',
+            description: 'A tag type with a valid color',
+            color: '#123ABC',
+        })
+        .set('Content-Type', 'application/json')
+        .expect(200);
+
+    expect(res.body.valid).toBe(true);
+});
+
+test('Validation of tag-types with invalid color format is unsuccessful', async () => {
+    const res = await app.request
+        .post('/api/admin/tag-types/validate')
+        .send({
+            name: 'invalid-color-validation',
+            description: 'A tag type with an invalid color',
+            color: 'not-a-color',
+        })
+        .set('Content-Type', 'application/json')
+        .expect(400);
+
+    expect(res.body.details[0].message).toMatch(/color/);
+});
+
 test('Validation of tag types allows numbers for description and icons because of coercion', async () => {
     await app.request
         .post('/api/admin/tag-types/validate')
@@ -216,3 +303,19 @@ test('Only required argument should be name', async () => {
             expect(res.body.name).toBe(name);
         });
 });
+
+test('Creating a tag type with null color is allowed', async () => {
+    const name = 'null-color-tag';
+    const res = await app.request
+        .post('/api/admin/tag-types')
+        .send({
+            name,
+            description: 'A tag with null color',
+            color: null,
+        })
+        .set('Content-Type', 'application/json')
+        .expect(201);
+
+    expect(res.body.name).toBe(name);
+    expect(res.body.color).toBe(null);
+});
diff --git a/src/lib/openapi/spec/tag-type-schema.ts b/src/lib/openapi/spec/tag-type-schema.ts
index 269bbffa43..9e1a53e09d 100644
--- a/src/lib/openapi/spec/tag-type-schema.ts
+++ b/src/lib/openapi/spec/tag-type-schema.ts
@@ -23,6 +23,13 @@ export const tagTypeSchema = {
             description: 'The icon of the tag type.',
             example: 'not-really-used',
         },
+        color: {
+            type: 'string',
+            nullable: true,
+            description: 'The hexadecimal color code for the tag type.',
+            example: '#FFFFFF',
+            pattern: '^#[0-9A-Fa-f]{6}$',
+        },
     },
     components: {},
 } as const;
diff --git a/src/lib/openapi/spec/tag-types-schema.test.ts b/src/lib/openapi/spec/tag-types-schema.test.ts
index 511db37ca9..aa0d005667 100644
--- a/src/lib/openapi/spec/tag-types-schema.test.ts
+++ b/src/lib/openapi/spec/tag-types-schema.test.ts
@@ -9,11 +9,13 @@ test('tagTypesSchema', () => {
                 name: 'simple',
                 description: 'Used to simplify filtering of features',
                 icon: '#',
+                color: '#FF0000',
             },
             {
                 name: 'hashtag',
                 description: '',
                 icon: null,
+                color: null,
             },
         ],
     };
diff --git a/src/lib/openapi/spec/update-tag-type-schema.ts b/src/lib/openapi/spec/update-tag-type-schema.ts
index 9844cbaff1..7713ba93e0 100644
--- a/src/lib/openapi/spec/update-tag-type-schema.ts
+++ b/src/lib/openapi/spec/update-tag-type-schema.ts
@@ -15,6 +15,12 @@ export const updateTagTypeSchema = {
             description: 'The icon of the tag type.',
             example: 'not-really-used',
         },
+        color: {
+            type: 'string',
+            description: 'The hexadecimal color code for the tag type.',
+            example: '#FFFFFF',
+            pattern: '^#[0-9A-Fa-f]{6}$',
+        },
     },
     components: {},
 } as const;
diff --git a/src/lib/services/tag-type-schema.ts b/src/lib/services/tag-type-schema.ts
index 8770390cb2..fdc9d8580f 100644
--- a/src/lib/services/tag-type-schema.ts
+++ b/src/lib/services/tag-type-schema.ts
@@ -6,6 +6,10 @@ export const tagTypeSchema = Joi.object()
         name: customJoi.isUrlFriendly().min(2).max(50).required(),
         description: Joi.string().allow(''),
         icon: Joi.string().allow(null).allow(''),
+        color: Joi.string()
+            .pattern(/^#[0-9A-Fa-f]{6}$/)
+            .allow(null)
+            .allow(''),
     })
     .options({
         allowUnknown: false,


Reviews:
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T14:08:30Z
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T14:08:45Z
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T14:08:53Z
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T14:09:28Z
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T14:09:39Z
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T14:09:51Z
user: login: kwasniew
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T14:10:02Z
user: login: kwasniew
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T14:25:46Z

Review comments:
diff_hunk: @@ -96,6 +115,21 @@ test('Invalid tag types gets rejected', async () => {
         });
 });
 
+test('Tag type with invalid color format gets rejected', async () => {
+    await app.request
+        .post('/api/admin/tag-types')
+        .send({
+            name: 'invalid-color-tag',
+            description: 'A tag with invalid color',
+            color: 'not-a-color',
+        })
+        .set('Content-Type', 'application/json')
+        .expect(400)
+        .expect((res) => {
+            expect(res.body.details[0].message).toMatch(/color/);
path: src/lib/features/tag-type/tag-types.e2e.test.ts
user: login: kwasniew
body: weird pattern. other tests I saw were returning body from the await call and asserting on the next line instead of going down the callback rabbit hole
created_at: 2025-03-18T14:08:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -113,6 +147,33 @@ test('Can update a tag types description and icon', async () => {
             expect(res.body.tagType.icon).toBe('$');
         });
 });
+
+test('Can update a tag type color', async () => {
+    await app.request
+        .post('/api/admin/tag-types')
+        .send({
+            name: 'color-update-tag',
+            description: 'A tag type to test color updates',
+            color: '#FFFFFF',
+        })
+        .expect(201);
+
+    await app.request
+        .put('/api/admin/tag-types/color-update-tag')
+        .send({
+            color: '#00FF00',
+        })
+        .expect(200);
+
+    return app.request
+        .get('/api/admin/tag-types/color-update-tag')
+        .expect('Content-Type', /json/)
+        .expect(200)
+        .expect((res) => {
+            expect(res.body.tagType.color).toBe('#00FF00');
path: src/lib/features/tag-type/tag-types.e2e.test.ts
user: login: kwasniew
body: I'd move the assertion to the top level
created_at: 2025-03-18T14:08:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,6 +200,36 @@ test('Validation of tag-types returns 200 for valid tag-types', async () => {
         });
 });
 
+test('Validation of tag-types with valid color returns 200', async () => {
+    await app.request
+        .post('/api/admin/tag-types/validate')
+        .send({
+            name: 'color-validation',
+            description: 'A tag type with a valid color',
+            color: '#123ABC',
+        })
+        .set('Content-Type', 'application/json')
+        .expect(200)
+        .expect((res) => {
+            expect(res.body.valid).toBe(true);
path: src/lib/features/tag-type/tag-types.e2e.test.ts
user: login: kwasniew
body: same as previous ones
created_at: 2025-03-18T14:08:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,6 +200,36 @@ test('Validation of tag-types returns 200 for valid tag-types', async () => {
         });
 });
 
+test('Validation of tag-types with valid color returns 200', async () => {
path: src/lib/features/tag-type/tag-types.e2e.test.ts
user: login: kwasniew
body: "Successful validation of tag type color". 200 is impl detail not relevant for the test name
created_at: 2025-03-18T14:09:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,6 +200,36 @@ test('Validation of tag-types returns 200 for valid tag-types', async () => {
         });
 });
 
+test('Validation of tag-types with valid color returns 200', async () => {
+    await app.request
+        .post('/api/admin/tag-types/validate')
+        .send({
+            name: 'color-validation',
+            description: 'A tag type with a valid color',
+            color: '#123ABC',
+        })
+        .set('Content-Type', 'application/json')
+        .expect(200)
+        .expect((res) => {
+            expect(res.body.valid).toBe(true);
+        });
+});
+
+test('Validation of tag-types with invalid color format returns 400', async () => {
path: src/lib/features/tag-type/tag-types.e2e.test.ts
user: login: kwasniew
body: Failed validation of tag type color
created_at: 2025-03-18T14:09:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -216,3 +307,20 @@ test('Only required argument should be name', async () => {
             expect(res.body.name).toBe(name);
         });
 });
+
+test('Creating a tag type with null color is allowed', async () => {
+    const name = 'null-color-tag';
+    return app.request
+        .post('/api/admin/tag-types')
+        .send({
+            name,
+            description: 'A tag with null color',
+            color: null,
+        })
+        .set('Content-Type', 'application/json')
+        .expect(201)
+        .expect((res) => {
+            expect(res.body.name).toBe(name);
path: src/lib/features/tag-type/tag-types.e2e.test.ts
user: login: kwasniew
body: top level
created_at: 2025-03-18T14:09:51Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-03-18T14:04:57Z
body: [vc]: #o4E6djoDDydmog9wFMvFMR/lEZ4KK21B8t9W8YYqTxA=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ1bmxlYXNoLWRvY3MiLCJyb290RGlyZWN0b3J5Ijoid2Vic2l0ZSIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3VubGVhc2gtdGVhbS91bmxlYXNoLWRvY3MvMlhoNGpSUHIzZ0hWUTd1QkV4b0xVeE1BeWpiOSIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwicHJldmlld1VybCI6InVubGVhc2gtZG9jcy1naXQtZmVhdC10YWctdHlwZS1jb2xvcnMtYmFja2VuZC11bmxlYXNoLXRlYW0udmVyY2VsLmFwcCJ9LHsibmFtZSI6InVubGVhc2gtbW9ub3JlcG8tZnJvbnRlbmQiLCJyb290RGlyZWN0b3J5IjoiZnJvbnRlbmQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoidW5sZWFzaC1tb25vcmVwby1mcm9udGVuZC1naXQtZmVhdC10YWctdHlwZS03N2Q1MjAtdW5sZWFzaC10ZWFtLnZlcmNlbC5hcHAifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3VubGVhc2gtdGVhbS91bmxlYXNoLW1vbm9yZXBvLWZyb250ZW5kL0Npa2VlWVk1NzRENm9vZkVLM292N3hlUGQ0RXQiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJwcmV2aWV3VXJsIjoidW5sZWFzaC1tb25vcmVwby1mcm9udGVuZC1naXQtZmVhdC10YWctdHlwZS03N2Q1MjAtdW5sZWFzaC10ZWFtLnZlcmNlbC5hcHAifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **unleash-monorepo-frontend** | ✅ Ready ([Inspect](https://vercel.com/unleash-team/unleash-monorepo-frontend/CikeeYY574D6oofEK3ov7xePd4Et)) | [Visit Preview](https://unleash-monorepo-frontend-git-feat-tag-type-77d520-unleash-team.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/unleash-monorepo-frontend-git-feat-tag-type-77d520-unleash-team.vercel.app?via=pr-comment-feedback-link) | Mar 18, 2025 2:22pm |

<details><summary>1 Skipped Deployment</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **unleash-docs** | ⬜️ Ignored ([Inspect](https://vercel.com/unleash-team/unleash-docs/2Xh4jRPr3gHVQ7uBExoLUxMAyjb9)) | [Visit Preview](https://unleash-docs-git-feat-tag-type-colors-backend-unleash-team.vercel.app) |  | Mar 18, 2025 2:22pm |
</details>



author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-18T14:06:59Z
body: <h1>Dependency Review</h1>
✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.<h2>OpenSSF Scorecard</h2>
<table><tr><th>Package</th><th>Version</th><th>Score</th><th>Details</th></tr>
</table><h2>Scanned Files</h2>
<ul>


<!-- dependency-review-pr-comment-marker -->
author_association: CONTRIBUTOR