From a65c8baf56b39732c1a3a587041914c2e4a8b7ee Mon Sep 17 00:00:00 2001 From: Fredrik Strand Oseberg Date: Tue, 18 Mar 2025 15:27:41 +0100 Subject: [PATCH] Feat/tag type colors backend (#9565) Adds backend color support for tag types --- .../features/tag-type/tag-type-store-type.ts | 1 + src/lib/features/tag-type/tag-type-store.ts | 15 ++- src/lib/features/tag-type/tag-type.ts | 12 +- .../features/tag-type/tag-types.e2e.test.ts | 103 ++++++++++++++++++ src/lib/openapi/spec/tag-type-schema.ts | 7 ++ src/lib/openapi/spec/tag-types-schema.test.ts | 2 + .../openapi/spec/update-tag-type-schema.ts | 6 + src/lib/services/tag-type-schema.ts | 4 + 8 files changed, 145 insertions(+), 5 deletions(-) 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 { 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 { + async updateTagType({ + name, + description, + icon, + color, + }: ITagType): Promise { 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 { - 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,