mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 12:43:09 -05:00
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:
@@ -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' });
|
||||
|
||||
@@ -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'][],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
6
upcoming-release-notes/6746.md
Normal file
6
upcoming-release-notes/6746.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [pouwerkerk]
|
||||
---
|
||||
|
||||
Adds API support to manage tags (`getTags`, `createTag`, `updateTag`, `deleteTag`).
|
||||
Reference in New Issue
Block a user