diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 17ac8f30..5cd9c1a3 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -41,6 +41,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l - (#2060) Added date-type custom fields to the task right-click menu under **Custom dates**. Thanks to @chmac for requesting this. - (#2067) Added an **Edit task** action to the task card context menu, including inline task cards. Thanks to @DarkCellar for requesting this. - (#2068) Added `reminders` input support to the MCP `tasknotes_create_task` and `tasknotes_update_task` tools. Thanks to @Spirit597 for requesting this. +- (#2069) Added an **Add project** action to the Quick Actions palette. Thanks to @chmac for requesting this. ## Fixed diff --git a/src/modals/TaskActionPaletteModal.ts b/src/modals/TaskActionPaletteModal.ts index a908bc93..8520aca6 100644 --- a/src/modals/TaskActionPaletteModal.ts +++ b/src/modals/TaskActionPaletteModal.ts @@ -4,6 +4,7 @@ import { FuzzyMatch, setIcon, Notice, + TAbstractFile, TFile, moment as obsidianMoment, } from "obsidian"; @@ -15,6 +16,8 @@ import { openOrCreateOccurrenceNote, } from "../ui/occurrenceNoteActions"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; +import { ProjectSelectModal } from "./ProjectSelectModal"; +import { addTaskToProject } from "../services/taskRelationshipActions"; const tasknotesLogger = createTaskNotesLogger({ tag: "Modals/TaskActionPaletteModal" }); @@ -225,21 +228,35 @@ export class TaskActionPaletteModal extends FuzzySuggestModal { } // Organization actions - actions.push({ - id: "toggle-archive", - title: this.task.archived ? "Unarchive task" : "Archive task", - description: this.task.archived - ? "Move task back to active tasks" - : "Archive this task", - icon: this.task.archived ? "archive-restore" : "archive", - category: "organization", - keywords: ["archive", this.task.archived ? "unarchive" : "archive", "organize"], - isApplicable: () => true, - execute: async (task) => { - await this.plugin.toggleTaskArchive(task); - new Notice(task.archived ? "Task unarchived" : "Task archived"); + actions.push( + { + id: "add-project", + title: "Add project", + description: "Select a project note and add it to this task", + icon: "folder-plus", + category: "organization", + keywords: ["project", "organization", "add", "link"], + isApplicable: () => true, + execute: async (task) => { + this.openProjectSelector(task); + }, }, - }); + { + id: "toggle-archive", + title: this.task.archived ? "Unarchive task" : "Archive task", + description: this.task.archived + ? "Move task back to active tasks" + : "Archive this task", + icon: this.task.archived ? "archive-restore" : "archive", + category: "organization", + keywords: ["archive", this.task.archived ? "unarchive" : "archive", "organize"], + isApplicable: () => true, + execute: async (task) => { + await this.plugin.toggleTaskArchive(task); + new Notice(task.archived ? "Task unarchived" : "Task archived"); + }, + } + ); // Recurring task actions (only for recurring tasks) if (this.task.recurrence) { @@ -465,6 +482,42 @@ export class TaskActionPaletteModal extends FuzzySuggestModal { }); } + private openProjectSelector(task: TaskInfo): void { + const selector = new ProjectSelectModal(this.plugin.app, this.plugin, (projectFile) => { + void this.addSelectedProjectToTask(task, projectFile); + }); + this.close(); + selector.open(); + } + + private async addSelectedProjectToTask( + task: TaskInfo, + projectFile: TAbstractFile + ): Promise { + try { + if (!(projectFile instanceof TFile)) { + new Notice( + this.plugin.i18n.translate( + "contextMenus.task.organization.notices.projectSelectFailed" + ) + ); + return; + } + + await addTaskToProject(this.plugin, task, projectFile); + } catch (error) { + tasknotesLogger.error("Failed to add task to project:", { + category: "persistence", + operation: "add-task-project", + details: { taskPath: task.path }, + error: error instanceof Error ? error.message : String(error), + }); + new Notice( + this.plugin.i18n.translate("contextMenus.task.organization.notices.addToProjectFailed") + ); + } + } + getItems(): TaskAction[] { // Filter to only applicable actions and sort by category and title return this.actions diff --git a/tests/unit/issues/issue-2069-quick-actions-add-project.test.ts b/tests/unit/issues/issue-2069-quick-actions-add-project.test.ts new file mode 100644 index 00000000..b546d6e2 --- /dev/null +++ b/tests/unit/issues/issue-2069-quick-actions-add-project.test.ts @@ -0,0 +1,113 @@ +import { App, TFile } from "obsidian"; +import { TaskActionPaletteModal } from "../../../src/modals/TaskActionPaletteModal"; +import { ProjectSelectModal } from "../../../src/modals/ProjectSelectModal"; +import { addTaskToProject } from "../../../src/services/taskRelationshipActions"; +import type TaskNotesPlugin from "../../../src/main"; +import type { TaskInfo } from "../../../src/types"; + +const mockProjectSelectModalOpen = jest.fn(); +const mockProjectSelectModalCallbacks: Array<(file: TFile) => void> = []; + +jest.mock("../../../src/modals/ProjectSelectModal", () => ({ + ProjectSelectModal: jest.fn().mockImplementation((_app, _plugin, onChoose) => { + mockProjectSelectModalCallbacks.push(onChoose); + return { + open: mockProjectSelectModalOpen, + }; + }), +})); + +jest.mock("../../../src/services/taskRelationshipActions", () => ({ + addTaskToProject: jest.fn(), +})); + +function createTask(overrides: Partial = {}): TaskInfo { + return { + id: "Tasks/quick-actions-project.md", + path: "Tasks/quick-actions-project.md", + title: "Quick Actions project task", + status: "open", + priority: "normal", + projects: [], + ...overrides, + } as TaskInfo; +} + +function createPlugin(): TaskNotesPlugin { + const app = new App(); + return { + app, + i18n: { + translate: jest.fn((key: string) => key), + }, + statusManager: { + getAllStatuses: jest.fn(() => [{ value: "open", label: "Open" }]), + getNonCompletionStatuses: jest.fn(() => [{ value: "open", label: "Open" }]), + }, + priorityManager: { + getAllPriorities: jest.fn(() => [{ value: "normal", label: "Normal" }]), + }, + cacheManager: { + getTaskInfo: jest.fn(), + }, + updateTaskProperty: jest.fn(), + getActiveTimeSession: jest.fn(() => null), + stopTimeTracking: jest.fn(), + startTimeTracking: jest.fn(), + openDueDateModal: jest.fn(), + openScheduledDateModal: jest.fn(), + openTimeEntryEditor: jest.fn(), + toggleTaskArchive: jest.fn(), + openTaskEditModal: jest.fn(), + } as unknown as TaskNotesPlugin; +} + +function createModal(task: TaskInfo, plugin: TaskNotesPlugin): TaskActionPaletteModal { + return new TaskActionPaletteModal( + new App() as never, + task, + plugin, + new Date("2026-06-23T10:00:00Z") + ); +} + +describe("Issue #2069: Quick Actions add project action", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockProjectSelectModalCallbacks.length = 0; + }); + + it("exposes Add project as an organization action", () => { + const modal = createModal(createTask(), createPlugin()); + + const action = modal.getItems().find((item) => item.id === "add-project"); + + expect(action).toEqual( + expect.objectContaining({ + title: "Add project", + category: "organization", + icon: "folder-plus", + }) + ); + }); + + it("opens the project selector and adds the chosen project through the shared helper", async () => { + const task = createTask(); + const plugin = createPlugin(); + const projectFile = new TFile("Projects/Alpha.md"); + const modal = createModal(task, plugin); + const closeSpy = jest.spyOn(modal, "close"); + const action = modal.getItems().find((item) => item.id === "add-project"); + + await action?.execute(task, plugin, new Date("2026-06-23T10:00:00Z")); + mockProjectSelectModalCallbacks[0](projectFile); + + expect(ProjectSelectModal).toHaveBeenCalledWith(plugin.app, plugin, expect.any(Function)); + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(mockProjectSelectModalOpen).toHaveBeenCalledTimes(1); + expect(closeSpy.mock.invocationCallOrder[0]).toBeLessThan( + mockProjectSelectModalOpen.mock.invocationCallOrder[0] + ); + expect(addTaskToProject).toHaveBeenCalledWith(plugin, task, projectFile); + }); +});