add quick actions project assignment

This commit is contained in:
callumalpass 2026-06-23 21:00:57 +10:00
parent 5a8eb76fb1
commit cac4f5e170
3 changed files with 181 additions and 14 deletions

View file

@ -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

View file

@ -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<TaskAction> {
}
// 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<TaskAction> {
});
}
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<void> {
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

View file

@ -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> = {}): 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);
});
});