diff --git a/docs/HTTP_API.md b/docs/HTTP_API.md index 6920db99e..343577d4e 100644 --- a/docs/HTTP_API.md +++ b/docs/HTTP_API.md @@ -211,6 +211,8 @@ Update task with partial payload. Configured TaskNotes user fields can be updated either by their frontmatter property key or via `customProperties`. +Sending an empty array for `contexts` or `blockedBy` clears the corresponding frontmatter field. + ```bash curl -X PUT "http://localhost:8080/api/tasks/TaskNotes%2FTasks%2FReview%20docs.md" \ -H "Content-Type: application/json" \ diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a03..cc1bd1185 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> + +## Fixed + +- Fixed `PUT /api/tasks/:id` ignoring empty arrays for `contexts` and `blockedBy`: sending `{"contexts": []}` or `{"blockedBy": []}` now clears the corresponding frontmatter field instead of silently leaving the previous value in place. The deletion pass previously fired only on a literal `undefined`, which JSON cannot express, so HTTP clients had no way to clear these fields. Thanks to @tgrosinger for the contribution. diff --git a/src/services/task-service/taskUpdatePlanning.ts b/src/services/task-service/taskUpdatePlanning.ts index 324608744..460569e00 100644 --- a/src/services/task-service/taskUpdatePlanning.ts +++ b/src/services/task-service/taskUpdatePlanning.ts @@ -307,7 +307,7 @@ function removeUnsetMappedFields( } if ( Object.prototype.hasOwnProperty.call(updates, "contexts") && - updates.contexts === undefined + (!Array.isArray(updates.contexts) || updates.contexts.length === 0) ) { delete frontmatter[fieldMapper.toUserField("contexts")]; } @@ -356,7 +356,7 @@ function removeUnsetMappedFields( } if ( Object.prototype.hasOwnProperty.call(updates, "blockedBy") && - updates.blockedBy === undefined + (!Array.isArray(updates.blockedBy) || updates.blockedBy.length === 0) ) { delete frontmatter[fieldMapper.toUserField("blockedBy")]; } diff --git a/tests/unit/services/taskUpdatePlanning.test.ts b/tests/unit/services/taskUpdatePlanning.test.ts index 31215bfc9..5c83ca4fe 100644 --- a/tests/unit/services/taskUpdatePlanning.test.ts +++ b/tests/unit/services/taskUpdatePlanning.test.ts @@ -232,6 +232,85 @@ describe("taskUpdatePlanning", () => { expect(result.finalTags).toEqual([]); }); + it("clears contexts and blockedBy when an update explicitly sets them to empty arrays", () => { + const frontmatter: Record = { + title: "Old", + status: "open", + contexts: ["old"], + blockedBy: [{ uid: "[[Other]]", reltype: "FINISHTOSTART" }], + tags: ["task"], + }; + + applyTaskUpdateFrontmatterChange({ + frontmatter, + originalTask: createTask(), + updates: { contexts: [], blockedBy: [] }, + recurrenceUpdates: {}, + dateModified: "2026-05-19T09:00:00.000Z", + fieldMapper: createFieldMapper(), + taskIdentification: { + method: "tag", + tag: "task", + propertyName: "", + propertyValue: "", + }, + storeTitleInFilename: false, + updateCompletedDateInFrontmatter: jest.fn(), + }); + + expect(frontmatter).not.toHaveProperty("contexts"); + expect(frontmatter).not.toHaveProperty("blockedBy"); + }); + + it("leaves contexts and blockedBy alone when the update omits them or keeps values", () => { + const frontmatter: Record = { + title: "Old", + status: "open", + contexts: ["old"], + blockedBy: [{ uid: "[[Other]]", reltype: "FINISHTOSTART" }], + tags: ["task"], + }; + + applyTaskUpdateFrontmatterChange({ + frontmatter, + originalTask: createTask(), + updates: { title: "Renamed" }, + recurrenceUpdates: {}, + dateModified: "2026-05-19T09:00:00.000Z", + fieldMapper: createFieldMapper(), + taskIdentification: { + method: "tag", + tag: "task", + propertyName: "", + propertyValue: "", + }, + storeTitleInFilename: false, + updateCompletedDateInFrontmatter: jest.fn(), + }); + + expect(frontmatter.contexts).toEqual(["old"]); + expect(frontmatter.blockedBy).toEqual([{ uid: "[[Other]]", reltype: "FINISHTOSTART" }]); + + applyTaskUpdateFrontmatterChange({ + frontmatter, + originalTask: createTask(), + updates: { contexts: ["new"] }, + recurrenceUpdates: {}, + dateModified: "2026-05-19T09:00:00.000Z", + fieldMapper: createFieldMapper(), + taskIdentification: { + method: "tag", + tag: "task", + propertyName: "", + propertyValue: "", + }, + storeTitleInFilename: false, + updateCompletedDateInFrontmatter: jest.fn(), + }); + + expect(frontmatter.contexts).toEqual(["new"]); + }); + it("builds the returned task state from the same planned mutation", () => { const updated = buildUpdatedTaskFromPlan({ originalTask: createTask({ completedDate: undefined }),