diff --git a/packages/api/methods.test.ts b/packages/api/methods.test.ts index d6b6bc2207..f1fffe8df1 100644 --- a/packages/api/methods.test.ts +++ b/packages/api/methods.test.ts @@ -356,6 +356,143 @@ describe('API CRUD operations', () => { ); }); + // apis: createTag, getTags, updateTag, deleteTag + test('Tags: successfully complete tag operations', async () => { + // Create tags + const tagId1 = await api.createTag({ tag: 'test-tag1', color: '#ff0000' }); + const tagId2 = await api.createTag({ + tag: 'test-tag2', + description: 'A test tag', + }); + + let tags = await api.getTags(); + expect(tags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: tagId1, + tag: 'test-tag1', + color: '#ff0000', + }), + expect.objectContaining({ + id: tagId2, + tag: 'test-tag2', + description: 'A test tag', + }), + ]), + ); + + // Update tag + await api.updateTag(tagId1, { tag: 'updated-tag', color: '#00ff00' }); + tags = await api.getTags(); + expect(tags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: tagId1, + tag: 'updated-tag', + color: '#00ff00', + }), + ]), + ); + + // Delete tag + await api.deleteTag(tagId2); + tags = await api.getTags(); + expect(tags).not.toEqual( + expect.arrayContaining([expect.objectContaining({ id: tagId2 })]), + ); + }); + + test('Tags: create tag with minimal fields', async () => { + const tagId = await api.createTag({ tag: 'minimal-tag' }); + const tags = await api.getTags(); + expect(tags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: tagId, + tag: 'minimal-tag', + color: null, + description: null, + }), + ]), + ); + }); + + test('Tags: update single field only', async () => { + const tagId = await api.createTag({ tag: 'original', color: '#ff0000' }); + + // Update only color, tag and description should remain unchanged + await api.updateTag(tagId, { color: '#00ff00' }); + + const tags = await api.getTags(); + expect(tags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: tagId, + tag: 'original', + color: '#00ff00', + description: null, + }), + ]), + ); + }); + + test('Tags: handle null values correctly', async () => { + const tagId = await api.createTag({ + tag: 'with-nulls', + color: null, + description: null, + }); + + const tags = await api.getTags(); + expect(tags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: tagId, + color: null, + description: null, + }), + ]), + ); + }); + + test('Tags: clear optional field', async () => { + const tagId = await api.createTag({ + tag: 'clearable', + color: '#ff0000', + description: 'will be cleared', + }); + + // Clear color by setting to null + await api.updateTag(tagId, { color: null }); + + let tags = await api.getTags(); + expect(tags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: tagId, + tag: 'clearable', + color: null, + description: 'will be cleared', + }), + ]), + ); + + // Clear description by setting to null + await api.updateTag(tagId, { description: null }); + + tags = await api.getTags(); + expect(tags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: tagId, + tag: 'clearable', + color: null, + description: null, + }), + ]), + ); + }); + // apis: getRules, getPayeeRules, createRule, updateRule, deleteRule test('Rules: successfully update rules', async () => { await api.createPayee({ name: 'test-payee' }); diff --git a/packages/api/methods.ts b/packages/api/methods.ts index cbfa1499ad..1f19215186 100644 --- a/packages/api/methods.ts +++ b/packages/api/methods.ts @@ -5,6 +5,7 @@ import type { APIFileEntity, APIPayeeEntity, APIScheduleEntity, + APITagEntity, } from 'loot-core/server/api-models'; import type { Query } from 'loot-core/shared/query'; import type { Handlers } from 'loot-core/types/handlers'; @@ -274,6 +275,25 @@ export function deletePayee(id: APIPayeeEntity['id']) { return send('api/payee-delete', { id }); } +export function getTags() { + return send('api/tags-get'); +} + +export function createTag(tag: Omit) { + return send('api/tag-create', { tag }); +} + +export function updateTag( + id: APITagEntity['id'], + fields: Partial>, +) { + return send('api/tag-update', { id, fields }); +} + +export function deleteTag(id: APITagEntity['id']) { + return send('api/tag-delete', { id }); +} + export function mergePayees( targetId: APIPayeeEntity['id'], mergeIds: APIPayeeEntity['id'][], diff --git a/packages/loot-core/src/server/api-models.ts b/packages/loot-core/src/server/api-models.ts index 6edb0385f1..9e6e764a0d 100644 --- a/packages/loot-core/src/server/api-models.ts +++ b/packages/loot-core/src/server/api-models.ts @@ -5,6 +5,7 @@ import type { CategoryGroupEntity, PayeeEntity, ScheduleEntity, + TagEntity, } from '../types/models'; import type { RemoteFile } from './cloud-storage'; @@ -117,6 +118,26 @@ export const payeeModel = { }, }; +export type APITagEntity = Pick< + TagEntity, + 'id' | 'tag' | 'color' | 'description' +>; + +export const tagModel = { + toExternal(tag: TagEntity): APITagEntity { + return { + id: tag.id, + tag: tag.tag, + color: tag.color ?? null, + description: tag.description ?? null, + }; + }, + + fromExternal(tag: Partial): Partial { + return tag; + }, +}; + export type APIFileEntity = Omit & { id?: string; cloudFileId: string; diff --git a/packages/loot-core/src/server/api.ts b/packages/loot-core/src/server/api.ts index ec5020bc26..1a4ac9c995 100644 --- a/packages/loot-core/src/server/api.ts +++ b/packages/loot-core/src/server/api.ts @@ -34,6 +34,7 @@ import { payeeModel, remoteFileModel, scheduleModel, + tagModel, } from './api-models'; import type { AmountOPType, APIScheduleEntity } from './api-models'; import { aqlQuery } from './aql'; @@ -740,6 +741,32 @@ handlers['api/payees-merge'] = withMutation(async function ({ return handlers['payees-merge']({ targetId, mergeIds }); }); +handlers['api/tags-get'] = async function () { + checkFileOpen(); + const tags = await handlers['tags-get'](); + return tags.map(tagModel.toExternal); +}; + +handlers['api/tag-create'] = withMutation(async function ({ tag }) { + checkFileOpen(); + const result = await handlers['tags-create']({ + tag: tag.tag, + color: tag.color, + description: tag.description, + }); + return result.id; +}); + +handlers['api/tag-update'] = withMutation(async function ({ id, fields }) { + checkFileOpen(); + await handlers['tags-update']({ id, ...tagModel.fromExternal(fields) }); +}); + +handlers['api/tag-delete'] = withMutation(async function ({ id }) { + checkFileOpen(); + await handlers['tags-delete']({ id }); +}); + handlers['api/rules-get'] = async function () { checkFileOpen(); return handlers['rules-get'](); diff --git a/packages/loot-core/src/server/tags/app.ts b/packages/loot-core/src/server/tags/app.ts index 8c876a6495..17b6e86c9e 100644 --- a/packages/loot-core/src/server/tags/app.ts +++ b/packages/loot-core/src/server/tags/app.ts @@ -54,7 +54,7 @@ async function createTag({ return { id, tag, color, description }; } -async function deleteTag(tag: TagEntity): Promise { +async function deleteTag(tag: Pick): Promise { await db.deleteTag(tag); return tag.id; } @@ -70,7 +70,9 @@ async function deleteAllTags( return ids; } -async function updateTag(tag: TagEntity): Promise { +async function updateTag( + tag: Partial & Pick, +): Promise> { await db.updateTag(tag); return tag; } diff --git a/packages/loot-core/src/types/api-handlers.ts b/packages/loot-core/src/types/api-handlers.ts index 2561322174..5f9b4a9f93 100644 --- a/packages/loot-core/src/types/api-handlers.ts +++ b/packages/loot-core/src/types/api-handlers.ts @@ -9,6 +9,7 @@ import type { APIFileEntity, APIPayeeEntity, APIScheduleEntity, + APITagEntity, } from '../server/api-models'; import type { BudgetFileHandlers } from '../server/budgetfiles/app'; import type { batchUpdateTransactions } from '../server/transactions'; @@ -217,6 +218,19 @@ export type ApiHandlers = { mergeIds: string[]; }) => Promise; + 'api/tags-get': () => Promise; + + 'api/tag-create': (arg: { + tag: Omit; + }) => Promise; + + 'api/tag-update': (arg: { + id: APITagEntity['id']; + fields: Partial>; + }) => Promise; + + 'api/tag-delete': (arg: { id: APITagEntity['id'] }) => Promise; + 'api/rules-get': () => Promise; 'api/payee-rules-get': (arg: { diff --git a/upcoming-release-notes/6746.md b/upcoming-release-notes/6746.md new file mode 100644 index 0000000000..9956dce99e --- /dev/null +++ b/upcoming-release-notes/6746.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [pouwerkerk] +--- + +Adds API support to manage tags (`getTags`, `createTag`, `updateTag`, `deleteTag`).