actualbudget / actualbudget/actual
[Bug]: api.updateCategory throws TypeError on any partial update that omits name
- Lingua principale
- TypeScript
- Stelle
- 28.7k
- Fork
- 3k
- Merge medio
- 2g 11h
- PR unite (30g)
- 65
Descrizione
### What happened?
`api.updateCategory(id, fields)` throws a `TypeError` for **any** partial update that does not include `name`, even though `fields` is documented and typed as a partial.
I was trying to move a category into a different group during a category restructure, without changing its name.
- **Expected:** the category moves to the new group, name untouched.
- **Actual:** `TypeError: Cannot read properties of undefined (reading 'trim')`
The same shape breaks a `hidden`-only update, and any other single-field update that omits `name`.
Sending the current name unchanged alongside the real change succeeds, which is the workaround I'm using:
```js
await api.updateCategory(categoryId, { group_id: groupId, name: currentName }); // works
```
### How can we reproduce the issue?
1. Open any budget file via `@actual-app/api` (`api.init(...)` then `api.downloadBudget(...)`).
2. Get a valid category id and a valid category-group id, e.g. from `await api.getCategories()` and `await api.getCategoryGroups()`.
3. Call `updateCategory` with a partial that omits `name`:
```js
await api.updateCategory(categoryId, { group_id: groupId });
```
4. **Expected:** resolves; the category is now in the new group with its name unchanged.
**Actual:** rejects with `TypeError: Cannot read properties of undefined (reading 'trim')`.
5. Confirm it is the omission of `name` that matters, by re-running with the name included — this succeeds:
```js
await api.updateCategory(categoryId, { group_id: groupId, name: currentName });
```
Nothing is written when it throws: the category is left untouched.
### Root cause
`updateCategory` normalizes `name` unconditionally, before checking whether it was supplied:
```js
async function updateCategory$1(category) {
try {
await updateCategory$2(categoryModel$1.toDb({
...category,
name: category.name.trim() // <-- throws when `name` was not passed
}));
```
The public entry point preserves the absence of the key, so `name` really is `undefined` by the time it reaches the trim:
`api.updateCategory(id, fields)` → `handlers["api/category-update"]` → `{ id, ...categoryModel.fromExternal(fields) }` → `handlers["category-update"]` → `updateCategory$1`.
Verified present in **26.8.1** and still present in **26.9.0** (`dist/index.js:118200`).
### Scope
Category-only — I checked the two sibling handlers and neither has the pattern:
- `handlers["api/payee-update"]` → `payeeModel.fromExternal(fields)`, no unconditional name access
- `updateCategoryGroup$1` → `categoryGroupModel$1.toDb(group)`, no trim
### Suggested fix
Only normalize `name` when it is present, and build the update payload from the keys actually passed rather than a fixed field list:
```js
await updateCategory$2(categoryModel$1.toDb({
...category,
...(typeof category.name === 'string' ? { name: category.name.trim() } : {}),
}));
```
### Suggested tests
- update `group_id` only → succeeds, name unchanged
- update `hidden` only → succeeds, name unchanged
- update `name` only → succeeds, group unchanged
- update with empty `fields` `{}` → clean validation error, not a `TypeError`
### Where are you hosting Actual?
Docker (self-hosted, `actualbudget/actual-server` 26.8.1)
### Operating System
Linux
### Additional environment
- `@actual-app/api` 26.8.1; the offending line also confirmed by inspection in 26.9.0
- Reached via the API, not the web UI, so no browser is involved
Guida per i contributori
Apri la guida per i contributori
Valutazione
Questa issue non è ancora stata valutata.