Add Tag API (#6746)

* Add Tag API

* Add Tag API tests

* Add Release Note for #6746

* Make release note more user-facing

* Remove unnecessary type coercion in tagModel.fromExternal

Since APITagEntity picks all properties from TagEntity, the types are
structurally identical and TypeScript can verify compatibility without
manual coercion.

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
This commit is contained in:
Pieter Ouwerkerk
2026-02-10 09:22:55 -08:00
committed by GitHub
parent cdaf06abee
commit 24f698910a
7 changed files with 229 additions and 2 deletions

View File

@@ -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' });

View File

@@ -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<APITagEntity, 'id'>) {
return send('api/tag-create', { tag });
}
export function updateTag(
id: APITagEntity['id'],
fields: Partial<Omit<APITagEntity, 'id'>>,
) {
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'][],

View File

@@ -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<APITagEntity>): Partial<TagEntity> {
return tag;
},
};
export type APIFileEntity = Omit<RemoteFile, 'deleted' | 'fileId'> & {
id?: string;
cloudFileId: string;

View File

@@ -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']();

View File

@@ -54,7 +54,7 @@ async function createTag({
return { id, tag, color, description };
}
async function deleteTag(tag: TagEntity): Promise<TagEntity['id']> {
async function deleteTag(tag: Pick<TagEntity, 'id'>): Promise<TagEntity['id']> {
await db.deleteTag(tag);
return tag.id;
}
@@ -70,7 +70,9 @@ async function deleteAllTags(
return ids;
}
async function updateTag(tag: TagEntity): Promise<TagEntity> {
async function updateTag(
tag: Partial<TagEntity> & Pick<TagEntity, 'id'>,
): Promise<Partial<TagEntity>> {
await db.updateTag(tag);
return tag;
}

View File

@@ -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<void>;
'api/tags-get': () => Promise<APITagEntity[]>;
'api/tag-create': (arg: {
tag: Omit<APITagEntity, 'id'>;
}) => Promise<APITagEntity['id']>;
'api/tag-update': (arg: {
id: APITagEntity['id'];
fields: Partial<Omit<APITagEntity, 'id'>>;
}) => Promise<void>;
'api/tag-delete': (arg: { id: APITagEntity['id'] }) => Promise<void>;
'api/rules-get': () => Promise<RuleEntity[]>;
'api/payee-rules-get': (arg: {

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [pouwerkerk]
---
Adds API support to manage tags (`getTags`, `createTag`, `updateTag`, `deleteTag`).