diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 0319c7339..5f84bfc59 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -34,6 +34,8 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Added +- (#2436, #909) Added an opt-in setting to use default projects only when no project is specified, so selecting a project or entering one with NLP can replace the defaults. Existing additive behavior remains unchanged by default. See [Projects settings](https://tasknotes.dev/settings/task-properties/#projects). Thanks to @RumiaKitinari for the suggestion. + - (#1955) Added Italian interface translations, localized dates and count-aware messages in bundled builds and downloadable language packs. UI language and NLP language remain independent settings. - Thanks to @starise for contributing and reviewing the Italian translation. diff --git a/docs/settings/task-properties.md b/docs/settings/task-properties.md index 7c7317c6d..5f6d36b80 100644 --- a/docs/settings/task-properties.md +++ b/docs/settings/task-properties.md @@ -133,6 +133,7 @@ Projects the task belongs to. Configuration options: - **Property key**: Frontmatter field name (default: `projects`) - **Default projects**: Select project notes to automatically link to new tasks +- **Use default projects only when no project is specified**: Off by default. When enabled, choosing a project in the creation form or filling the form from natural-language input replaces the preloaded default projects. Instant conversion also omits defaults when the input specifies a project. Explicit selections and projects supplied by a Bases view or parent task are preserved. Clearing projects leaves the selection empty; it does not restore the defaults. - **Use active note for new tasks**: Automatically link the active note as a project when opening task creation from the command palette or ribbon - **Use parent note for inline/instant conversion**: Automatically link the source note as a project when using inline task creation or instant task conversion - **NLP trigger**: Character that triggers project parsing (default: `+`) diff --git a/e2e/issues/issue-2436-default-project-fallback.spec.ts b/e2e/issues/issue-2436-default-project-fallback.spec.ts new file mode 100644 index 000000000..7f056ff0a --- /dev/null +++ b/e2e/issues/issue-2436-default-project-fallback.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from "@playwright/test"; +import { launchObsidian, closeObsidian, type ObsidianApp } from "../obsidian"; + +let instance: ObsidianApp; +let original: any; +const prefix = `Issue2436-${Date.now()}`; + +test.beforeAll(async () => { + instance = await launchObsidian(); + original = await instance.page.evaluate(async (prefix) => { + const app = (window as any).app; + const plugin = app.plugins.plugins.tasknotes; + const original = JSON.parse(JSON.stringify(plugin.settings)); + await app.vault.create(`${prefix}-Homework.md`, "# Homework"); + await app.vault.create(`${prefix}-Personal.md`, "# Personal"); + plugin.settings.enableNaturalLanguageInput = true; + plugin.settings.nlpEnterToSubmit = false; + plugin.settings.openTaskAfterCreation = "none"; + plugin.settings.taskCreationDefaults.defaultProjects = `[[${prefix}-Homework]]`; + return original; + }, prefix); +}); + +test.afterAll(async () => { + if (!instance) return; + try { + await instance.page.evaluate(async ({ original, prefix }) => { + const app = (window as any).app; + app.plugins.plugins.tasknotes.settings = original; + for (const file of app.vault.getMarkdownFiles()) { + if (file.basename.startsWith(prefix)) await app.vault.delete(file); + } + }, { original, prefix }); + } finally { + await closeObsidian(instance); + } +}); + +for (const [name, enabled, explicit] of [ + ["legacy additive", false, true], + ["fallback override", true, true], + ["fallback without explicit project", true, false], +] as const) { + test(name, async () => { + const title = `${prefix}-${name}`; + await instance.page.evaluate((enabled) => { + const plugin = (window as any).app.plugins.plugins.tasknotes; + plugin.settings.taskCreationDefaults.defaultProjectsOnlyWhenEmpty = enabled; + plugin.openTaskCreationModal(); + }, enabled); + const editor = instance.page.locator(".modal .tn-task-modal__markdown-editor--nlp .cm-content"); + await expect(editor).toBeVisible(); + await editor.click(); + await instance.page.keyboard.insertText(title + (explicit ? ` +[[${prefix}-Personal]]` : "")); + await instance.page.locator(".modal").getByRole("button", { name: "Save", exact: true }).click(); + await expect(editor).toHaveCount(0); + const expected = explicit + ? enabled ? [`[[${prefix}-Personal]]`] : [`[[${prefix}-Homework]]`, `[[${prefix}-Personal]]`] + : [`[[${prefix}-Homework]]`]; + await expect.poll(() => instance.page.evaluate((title) => { + const app = (window as any).app; + const file = app.vault.getMarkdownFiles().find((file: any) => file.basename === title); + return file ? app.metadataCache.getFileCache(file)?.frontmatter?.projects : null; + }, title)).toEqual(expected); + }); +} diff --git a/src/i18n/locales/de/messages.po b/src/i18n/locales/de/messages.po index de79d54da..f3d0b5b45 100644 --- a/src/i18n/locales/de/messages.po +++ b/src/i18n/locales/de/messages.po @@ -13493,6 +13493,11 @@ msgctxt "settings.defaults.bodyTemplate.useBodyTemplate.name" msgid "Use body template" msgstr "Inhaltsvorlage verwenden" +#: src/settings/tabs/taskProperties/projectsPropertyCard.ts +msgctxt "settings.taskProperties.projectsCard.defaultProjectsOnlyWhenEmpty" +msgid "Use default projects only when no project is specified" +msgstr "Standardprojekte nur verwenden, wenn kein Projekt angegeben ist" + #: src/settings/tabs/integrationsTab.ts msgctxt "settings.integrations.calendarSubscriptions.useICSEndAsDue.name" msgid "Use ICS event end time as task due date" diff --git a/src/i18n/locales/en/messages.po b/src/i18n/locales/en/messages.po index df059a871..1e501a058 100644 --- a/src/i18n/locales/en/messages.po +++ b/src/i18n/locales/en/messages.po @@ -13493,6 +13493,11 @@ msgctxt "settings.defaults.bodyTemplate.useBodyTemplate.name" msgid "Use body template" msgstr "Use body template" +#: src/settings/tabs/taskProperties/projectsPropertyCard.ts +msgctxt "settings.taskProperties.projectsCard.defaultProjectsOnlyWhenEmpty" +msgid "Use default projects only when no project is specified" +msgstr "Use default projects only when no project is specified" + #: src/settings/tabs/integrationsTab.ts msgctxt "settings.integrations.calendarSubscriptions.useICSEndAsDue.name" msgid "Use ICS event end time as task due date" diff --git a/src/i18n/locales/es/messages.po b/src/i18n/locales/es/messages.po index 575632a48..3e3f94411 100644 --- a/src/i18n/locales/es/messages.po +++ b/src/i18n/locales/es/messages.po @@ -13493,6 +13493,11 @@ msgctxt "settings.defaults.bodyTemplate.useBodyTemplate.name" msgid "Use body template" msgstr "Usar plantilla del cuerpo" +#: src/settings/tabs/taskProperties/projectsPropertyCard.ts +msgctxt "settings.taskProperties.projectsCard.defaultProjectsOnlyWhenEmpty" +msgid "Use default projects only when no project is specified" +msgstr "Usar los proyectos predeterminados solo cuando no se especifique ningún proyecto" + #: src/settings/tabs/integrationsTab.ts msgctxt "settings.integrations.calendarSubscriptions.useICSEndAsDue.name" msgid "Use ICS event end time as task due date" diff --git a/src/i18n/locales/fr/messages.po b/src/i18n/locales/fr/messages.po index 446b02714..133db65f4 100644 --- a/src/i18n/locales/fr/messages.po +++ b/src/i18n/locales/fr/messages.po @@ -13493,6 +13493,11 @@ msgctxt "settings.defaults.bodyTemplate.useBodyTemplate.name" msgid "Use body template" msgstr "Utiliser un modèle de contenu" +#: src/settings/tabs/taskProperties/projectsPropertyCard.ts +msgctxt "settings.taskProperties.projectsCard.defaultProjectsOnlyWhenEmpty" +msgid "Use default projects only when no project is specified" +msgstr "Utiliser les projets par défaut uniquement si aucun projet n’est spécifié" + #: src/settings/tabs/integrationsTab.ts msgctxt "settings.integrations.calendarSubscriptions.useICSEndAsDue.name" msgid "Use ICS event end time as task due date" diff --git a/src/i18n/locales/it/messages.po b/src/i18n/locales/it/messages.po index 91bf74a3b..7784e044a 100644 --- a/src/i18n/locales/it/messages.po +++ b/src/i18n/locales/it/messages.po @@ -13492,6 +13492,11 @@ msgctxt "settings.defaults.bodyTemplate.useBodyTemplate.name" msgid "Use body template" msgstr "Usa modello per il contenuto dell’attività" +#: src/settings/tabs/taskProperties/projectsPropertyCard.ts +msgctxt "settings.taskProperties.projectsCard.defaultProjectsOnlyWhenEmpty" +msgid "Use default projects only when no project is specified" +msgstr "Usa i progetti predefiniti solo quando non è specificato alcun progetto" + #: src/settings/tabs/integrationsTab.ts msgctxt "settings.integrations.calendarSubscriptions.useICSEndAsDue.name" msgid "Use ICS event end time as task due date" diff --git a/src/i18n/locales/ja/messages.po b/src/i18n/locales/ja/messages.po index 4c2b62350..4c2e5f128 100644 --- a/src/i18n/locales/ja/messages.po +++ b/src/i18n/locales/ja/messages.po @@ -13492,6 +13492,11 @@ msgctxt "settings.defaults.bodyTemplate.useBodyTemplate.name" msgid "Use body template" msgstr "ボディテンプレートを使用" +#: src/settings/tabs/taskProperties/projectsPropertyCard.ts +msgctxt "settings.taskProperties.projectsCard.defaultProjectsOnlyWhenEmpty" +msgid "Use default projects only when no project is specified" +msgstr "プロジェクトが指定されていない場合のみ既定のプロジェクトを使用" + #: src/settings/tabs/integrationsTab.ts msgctxt "settings.integrations.calendarSubscriptions.useICSEndAsDue.name" msgid "Use ICS event end time as task due date" diff --git a/src/i18n/locales/ko/messages.po b/src/i18n/locales/ko/messages.po index 5f0ac3014..1bf343caf 100644 --- a/src/i18n/locales/ko/messages.po +++ b/src/i18n/locales/ko/messages.po @@ -13492,6 +13492,11 @@ msgctxt "settings.defaults.bodyTemplate.useBodyTemplate.name" msgid "Use body template" msgstr "본문 템플릿 사용" +#: src/settings/tabs/taskProperties/projectsPropertyCard.ts +msgctxt "settings.taskProperties.projectsCard.defaultProjectsOnlyWhenEmpty" +msgid "Use default projects only when no project is specified" +msgstr "프로젝트가 지정되지 않은 경우에만 기본 프로젝트 사용" + #: src/settings/tabs/integrationsTab.ts msgctxt "settings.integrations.calendarSubscriptions.useICSEndAsDue.name" msgid "Use ICS event end time as task due date" diff --git a/src/i18n/locales/pt/messages.po b/src/i18n/locales/pt/messages.po index 337cce10c..ec29d01df 100644 --- a/src/i18n/locales/pt/messages.po +++ b/src/i18n/locales/pt/messages.po @@ -13494,6 +13494,11 @@ msgctxt "settings.defaults.bodyTemplate.useBodyTemplate.name" msgid "Use body template" msgstr "Usar modelo de corpo" +#: src/settings/tabs/taskProperties/projectsPropertyCard.ts +msgctxt "settings.taskProperties.projectsCard.defaultProjectsOnlyWhenEmpty" +msgid "Use default projects only when no project is specified" +msgstr "Usar os projetos padrão apenas quando nenhum projeto for especificado" + #: src/settings/tabs/integrationsTab.ts msgctxt "settings.integrations.calendarSubscriptions.useICSEndAsDue.name" msgid "Use ICS event end time as task due date" diff --git a/src/i18n/locales/ru/messages.po b/src/i18n/locales/ru/messages.po index 6dfac3887..cdf504f9e 100644 --- a/src/i18n/locales/ru/messages.po +++ b/src/i18n/locales/ru/messages.po @@ -13493,6 +13493,11 @@ msgctxt "settings.defaults.bodyTemplate.useBodyTemplate.name" msgid "Use body template" msgstr "Использовать шаблон тела" +#: src/settings/tabs/taskProperties/projectsPropertyCard.ts +msgctxt "settings.taskProperties.projectsCard.defaultProjectsOnlyWhenEmpty" +msgid "Use default projects only when no project is specified" +msgstr "Использовать проекты по умолчанию, только если проект не указан" + #: src/settings/tabs/integrationsTab.ts msgctxt "settings.integrations.calendarSubscriptions.useICSEndAsDue.name" msgid "Use ICS event end time as task due date" diff --git a/src/i18n/locales/zh/messages.po b/src/i18n/locales/zh/messages.po index 635a6938c..c0edf9593 100644 --- a/src/i18n/locales/zh/messages.po +++ b/src/i18n/locales/zh/messages.po @@ -13492,6 +13492,11 @@ msgctxt "settings.defaults.bodyTemplate.useBodyTemplate.name" msgid "Use body template" msgstr "使用正文模板" +#: src/settings/tabs/taskProperties/projectsPropertyCard.ts +msgctxt "settings.taskProperties.projectsCard.defaultProjectsOnlyWhenEmpty" +msgid "Use default projects only when no project is specified" +msgstr "仅在未指定项目时使用默认项目" + #: src/settings/tabs/integrationsTab.ts msgctxt "settings.integrations.calendarSubscriptions.useICSEndAsDue.name" msgid "Use ICS event end time as task due date" diff --git a/src/modals/TaskCreationModal.ts b/src/modals/TaskCreationModal.ts index 515197dea..d93fdb1a6 100644 --- a/src/modals/TaskCreationModal.ts +++ b/src/modals/TaskCreationModal.ts @@ -1,9 +1,10 @@ import { displayError } from "../i18n/runtime"; import { uiText } from "../i18n/runtime"; import { msg } from "@lingui/core/macro"; -import { App, Notice, setIcon, setTooltip, TFile } from "obsidian"; +import { App, Notice, setIcon, setTooltip, TAbstractFile, TFile } from "obsidian"; import TaskNotesPlugin from "../main"; import { TaskModal } from "./TaskModal"; +import type { TaskModalProjectItem } from "./taskModalProjects"; import { TaskInfo } from "../types"; import { sanitizeTags } from "../utils/helpers"; import { @@ -106,6 +107,7 @@ function createEmbeddableMarkdownEditor( export class TaskCreationModal extends TaskModal { private options: TaskCreationOptions; + private defaultProjectItems = new Set(); private nlParser: NaturalLanguageParser; private nlInput: HTMLTextAreaElement = undefined as unknown as HTMLTextAreaElement; // Legacy - keeping for compatibility private nlMarkdownEditor: EmbeddableMarkdownEditor | null = null; @@ -620,7 +622,28 @@ export class TaskCreationModal extends TaskModal { } } + private clearDefaultProjectsForExplicitSelection(): void { + if (!this.plugin.settings.taskCreationDefaults.defaultProjectsOnlyWhenEmpty) return; + this.selectedProjectItems = this.selectedProjectItems.filter( + (item) => !this.defaultProjectItems.has(item) + ); + this.defaultProjectItems.clear(); + } + + protected addProject(file: TAbstractFile): void { + if (file instanceof TFile) this.clearDefaultProjectsForExplicitSelection(); + super.addProject(file); + } + + protected addProjectsFromStrings(projects: string[]): void { + if (projects.some((project) => project.trim().length > 0)) { + this.clearDefaultProjectsForExplicitSelection(); + } + super.addProjectsFromStrings(projects); + } + async initializeFormData(): Promise { + this.defaultProjectItems.clear(); const formState = buildTaskCreationFormState({ defaultPriority: this.plugin.settings.defaultTaskPriority, defaultStatus: this.plugin.settings.defaultTaskStatus, @@ -643,8 +666,11 @@ export class TaskCreationModal extends TaskModal { this.reminders = formState.reminders; this.userFields = formState.userFields; - if (formState.projectStrings.length > 0) { - this.initializeProjectsFromStrings(formState.projectStrings); + this.initializeProjectsFromStrings(formState.projectStrings); + // Only defaults have this provenance. Bases, parent/subtask and caller-supplied + // projects are intentional assignments, even if they name a default project. + if (this.options.prePopulatedValues?.projects === undefined) { + this.defaultProjectItems = new Set(this.selectedProjectItems); } this.details = this.normalizeDetails(this.details); diff --git a/src/services/InstantTaskConvertService.ts b/src/services/InstantTaskConvertService.ts index 83f47176a..cc48b8dc4 100644 --- a/src/services/InstantTaskConvertService.ts +++ b/src/services/InstantTaskConvertService.ts @@ -775,7 +775,10 @@ export class InstantTaskConvertService { // Apply default projects if defaults are enabled if (this.plugin.settings.useDefaultsOnInstantConvert) { const defaults = this.plugin.settings.taskCreationDefaults; - if (defaults.defaultProjects) { + if ( + defaults.defaultProjects && + !(defaults.defaultProjectsOnlyWhenEmpty && parsedProjects.length > 0) + ) { const defaultProjectsArray = splitListPreservingLinksAndQuotes( defaults.defaultProjects ); diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 14cd7e1aa..bef15567b 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -109,6 +109,7 @@ export const DEFAULT_TASK_CREATION_DEFAULTS: TaskCreationDefaults = { defaultContexts: "", defaultTags: "", defaultProjects: "", + defaultProjectsOnlyWhenEmpty: false, useParentNoteForTaskCreation: false, useParentNoteAsProject: false, useParentHeaderAsProject: false, diff --git a/src/settings/tabs/taskProperties/projectsPropertyCard.ts b/src/settings/tabs/taskProperties/projectsPropertyCard.ts index b4a0adf5c..6916ebaa5 100644 --- a/src/settings/tabs/taskProperties/projectsPropertyCard.ts +++ b/src/settings/tabs/taskProperties/projectsPropertyCard.ts @@ -105,6 +105,14 @@ export function renderProjectsPropertyCard( translate ); + const defaultProjectsOnlyWhenEmptyToggle = createCardToggle( + plugin.settings.taskCreationDefaults.defaultProjectsOnlyWhenEmpty, + (value) => { + plugin.settings.taskCreationDefaults.defaultProjectsOnlyWhenEmpty = value; + save(); + } + ); + const useParentNoteForTaskCreationToggle = createCardToggle( plugin.settings.taskCreationDefaults.useParentNoteForTaskCreation, (value) => { @@ -160,6 +168,10 @@ export function renderProjectsPropertyCard( input: nestedContainer, fullWidth: true, }, + { + label: translate(msg({ message: "Use default projects only when no project is specified", context: "settings.taskProperties.projectsCard.defaultProjectsOnlyWhenEmpty" })), + input: defaultProjectsOnlyWhenEmptyToggle, + }, { label: translate( msg({ message: "Use active note for new tasks:", context: "settings.taskProperties.projectsCard.useParentNoteForTaskCreation" }) diff --git a/src/types/settings.ts b/src/types/settings.ts index 6a74edacb..68247cd66 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -283,6 +283,7 @@ export interface TaskCreationDefaults { defaultContexts: string; // Comma-separated list defaultTags: string; // Comma-separated list defaultProjects: string; // Comma-separated list of project links + defaultProjectsOnlyWhenEmpty: boolean; // Explicit projects replace creation defaults useParentNoteForTaskCreation: boolean; // Use the active note as a project for normal task creation useParentNoteAsProject: boolean; // Use the parent note as a project during inline creation and instant conversion useParentHeaderAsProject: boolean; // Use the closest markdown heading as a project during instant conversion diff --git a/tests/unit/modals/TaskCreationModal.default-projects.test.ts b/tests/unit/modals/TaskCreationModal.default-projects.test.ts new file mode 100644 index 000000000..49714dd35 --- /dev/null +++ b/tests/unit/modals/TaskCreationModal.default-projects.test.ts @@ -0,0 +1,105 @@ +import { TFile } from "obsidian"; +import { TaskCreationModal } from "../../../src/modals/TaskCreationModal"; +import { MockObsidian } from "../../helpers/obsidian-runtime"; +import { DEFAULT_SETTINGS } from "../../../src/settings/defaults"; + +describe("creation default project provenance (#2436)", () => { + let app: any; + let plugin: any; + + beforeEach(() => { + MockObsidian.reset(); + app = MockObsidian.createMockApp(); + plugin = { + app, + settings: { + ...DEFAULT_SETTINGS, + taskCreationDefaults: { + ...DEFAULT_SETTINGS.taskCreationDefaults, + defaultProjects: "[[Homework]], [[School]]", + defaultProjectsOnlyWhenEmpty: true, + }, + }, + }; + }); + + async function create(projects?: string[]) { + const modal = new TaskCreationModal(app, plugin, projects === undefined ? {} : { + prePopulatedValues: { projects }, + }); + await modal.initializeFormData(); + const state = modal as any; + state.renderProjectsList = jest.fn(); + return state; + } + + it("keeps defaults when no project is specified", async () => { + const modal = await create(); + expect(modal.buildTaskData().projects).toEqual(["[[Homework]]", "[[School]]"]); + }); + + it("replaces all preloaded defaults with multiple NLP projects", async () => { + const modal = await create(); + modal.addProjectsFromStrings(["[[Personal Projects]]", "[[Errands]]"]); + expect(modal.buildTaskData().projects).toEqual(["[[Personal Projects]]", "[[Errands]]"]); + }); + + it("preserves the legacy additive behavior when disabled", async () => { + plugin.settings.taskCreationDefaults.defaultProjectsOnlyWhenEmpty = false; + const modal = await create(); + modal.addProjectsFromStrings(["[[Personal Projects]]"]); + expect(modal.buildTaskData().projects).toEqual(["[[Homework]]", "[[School]]", "[[Personal Projects]]"]); + }); + + it.each(["[[Parent]]", "[[Homework]]"])("preserves prepopulated project %s", async (project) => { + const modal = await create([project]); + modal.addProjectsFromStrings(["[[Personal Projects]]"]); + expect(modal.buildTaskData().projects).toEqual([project, "[[Personal Projects]]"]); + }); + + it("preserves an explicitly empty prepopulation", async () => { + const modal = await create([]); + expect(modal.selectedProjectItems).toEqual([]); + expect(modal.buildTaskData().projects).toBeUndefined(); + }); + + it("does not clear defaults for empty NLP project input", async () => { + const modal = await create(); + modal.addProjectsFromStrings([]); + expect(modal.buildTaskData().projects).toEqual(["[[Homework]]", "[[School]]"]); + }); + + it("keeps an explicitly chosen default without restoring the others", async () => { + const modal = await create(); + modal.addProjectsFromStrings(["[[Homework]]"]); + modal.addProjectsFromStrings(["[[Personal Projects]]"]); + expect(modal.buildTaskData().projects).toEqual(["[[Homework]]", "[[Personal Projects]]"]); + }); + + it("replaces defaults through the file picker and keeps later NLP selections", async () => { + const modal = await create(); + const file = new TFile(); + Object.assign(file, { path: "Personal Projects.md", basename: "Personal Projects" }); + modal.buildProjectReference = () => "[[Personal Projects]]"; + modal.addProject(file); + modal.addProjectsFromStrings(["[[Errands]]"]); + expect(modal.buildTaskData().projects).toEqual(["[[Personal Projects]]", "[[Errands]]"]); + }); + + it("does not restore defaults after the user removes the explicit project", async () => { + const modal = await create(); + modal.addProjectsFromStrings(["[[Personal Projects]]"]); + modal.removeProject(modal.selectedProjectItems[0]); + modal.addProjectsFromStrings([]); + expect(modal.selectedProjectItems).toEqual([]); + expect(modal.buildTaskData().projects).toBeUndefined(); + }); + + it("resets default provenance when initializing a fresh form", async () => { + const modal = await create(); + modal.addProjectsFromStrings(["[[Personal Projects]]"]); + await modal.initializeFormData(); + modal.addProjectsFromStrings(["[[Errands]]"]); + expect(modal.buildTaskData().projects).toEqual(["[[Errands]]"]); + }); +}); diff --git a/tests/unit/services/InstantTaskConvertService.default-projects.test.ts b/tests/unit/services/InstantTaskConvertService.default-projects.test.ts new file mode 100644 index 000000000..b2ce7fa5f --- /dev/null +++ b/tests/unit/services/InstantTaskConvertService.default-projects.test.ts @@ -0,0 +1,62 @@ +import { TFile } from "obsidian"; +import { InstantTaskConvertService } from "../../../src/services/InstantTaskConvertService"; +import { PluginFactory } from "../../helpers/mock-factories"; +import { DEFAULT_SETTINGS } from "../../../src/settings/defaults"; + +describe("instant conversion default projects (#2436)", () => { + let plugin: any; + let service: any; + + beforeEach(() => { + plugin = PluginFactory.createMockPlugin({ + settings: { + ...DEFAULT_SETTINGS, + useDefaultsOnInstantConvert: true, + taskCreationDefaults: { + ...DEFAULT_SETTINGS.taskCreationDefaults, + defaultDueDate: "none", + defaultScheduledDate: "none", + defaultProjects: "[[Homework]], [[School, Admin]]", + defaultProjectsOnlyWhenEmpty: true, + }, + }, + }); + plugin.app.workspace.getActiveFile = jest.fn().mockReturnValue(null); + plugin.taskService = { + createTask: jest.fn().mockResolvedValue({ file: { path: "task.md" }, taskInfo: {} }), + }; + service = new InstantTaskConvertService(plugin, plugin.statusManager, plugin.priorityManager); + }); + + async function projects(projects?: string[]) { + await service.createTaskFile({ title: "Test", projects }, ""); + return plugin.taskService.createTask.mock.calls[0][0].projects; + } + + it("uses defaults when the input has no project", async () => { + expect(await projects()).toEqual(["[[Homework]]", "[[School, Admin]]"]); + }); + + it("replaces defaults with all parsed projects", async () => { + expect(await projects(["[[Personal]]", "[[Errands]]"])).toEqual(["[[Personal]]", "[[Errands]]"]); + }); + + it("keeps additive behavior when the setting is off", async () => { + plugin.settings.taskCreationDefaults.defaultProjectsOnlyWhenEmpty = false; + expect(await projects(["[[Personal]]"])).toEqual(["[[Homework]]", "[[School, Admin]]", "[[Personal]]"]); + }); + + it("does not apply defaults when conversion defaults are off", async () => { + plugin.settings.useDefaultsOnInstantConvert = false; + expect(await projects(["[[Personal]]"])).toEqual(["[[Personal]]"]); + }); + + it("preserves the parent note when a parsed project replaces defaults", async () => { + const file = new TFile(); + Object.assign(file, { path: "Parent.md", basename: "Parent" }); + plugin.app.workspace.getActiveFile.mockReturnValue(file); + plugin.app.fileManager.generateMarkdownLink = jest.fn().mockReturnValue("[[Parent]]"); + plugin.settings.taskCreationDefaults.useParentNoteAsProject = true; + expect(await projects(["[[Personal]]"])).toEqual(["[[Parent]]", "[[Personal]]"]); + }); +});