Merge pull request #1679 from Lorite/fix_1678_open_the_timeblock_modal_with_the_task_title_prefilled_from_the_task_context_menu

# Conflicts:
#	docs/releases/unreleased.md
This commit is contained in:
callumalpass 2026-03-29 10:25:57 +11:00
commit d3203b203f
10 changed files with 355 additions and 3 deletions

View file

@ -53,6 +53,8 @@ Example:
## Added
- (#1678) Added task-first timeblock creation and editing helpers, including prefilled task-title context-menu creation, prefilled task attachments, Add Task actions in the create/edit modals, auto-fill of empty titles from selected attachments, and configurable attachment search ordering
- Thanks to @Lorite for the fix
- (#1619, #386, #621) Added drag-to-reorder for Kanban and Task List views, including grouped Task List moves, manual ordering support via the `tasknotes_manual_order` property, updated generated `.base` templates, and polished drag/drop feedback for swimlanes, filtered views, and interactive task controls
- Thanks to @ac8318740 for the original contribution in PR #1619
- Thanks to @iholston and @dsebastien for opening #386 and #621

View file

@ -18,6 +18,8 @@ import {
} from "../utils/dependencyUtils";
import { generateLink } from "../utils/linkUtils";
import { ContextMenu } from "./ContextMenu";
import { buildTimeblockPrefillForTask } from "../utils/timeblockPrefillUtils";
import { TimeblockCreationModal } from "../modals/TimeblockCreationModal";
export interface TaskContextMenuOptions {
task: TaskInfo;
@ -312,6 +314,25 @@ export class TaskContextMenu {
});
});
// Create timeblock from task
if (plugin.settings.calendarViewSettings.enableTimeblocking) {
this.menu.addItem((item) => {
item.setTitle("Create timeblock");
item.setIcon("calendar-plus");
item.onClick(() => {
const prefill = buildTimeblockPrefillForTask(task, this.options.targetDate);
const modal = new TimeblockCreationModal(plugin.app, plugin, {
date: prefill.date,
startTime: prefill.startTime,
endTime: prefill.endTime,
prefilledTitle: task.title,
prefilledAttachmentPaths: [task.path],
});
modal.open();
});
});
}
// Archive/Unarchive
this.menu.addItem((item) => {
item.setTitle(

View file

@ -24,6 +24,16 @@ export interface FileSelectorOptions {
filter?: "markdown" | "all" | ((file: TAbstractFile) => boolean);
/** Folder to create new files in (default: vault root) */
newFileFolder?: string;
/** Sort order for results */
sortOrder?:
| "name-asc"
| "name-desc"
| "path-asc"
| "path-desc"
| "created-recent"
| "created-oldest"
| "modified-recent"
| "modified-oldest";
}
/**
@ -223,12 +233,39 @@ export class FileSelectorModal extends SuggestModal<TAbstractFile> {
);
}
const sortOrder = this.options.sortOrder || "name-asc";
const sorted = [...filtered].sort((a, b) => {
const aCreated = a instanceof TFile ? a.stat.ctime : 0;
const bCreated = b instanceof TFile ? b.stat.ctime : 0;
const aModified = a instanceof TFile ? a.stat.mtime : 0;
const bModified = b instanceof TFile ? b.stat.mtime : 0;
switch (sortOrder) {
case "name-desc":
return b.name.localeCompare(a.name);
case "path-asc":
return a.path.localeCompare(b.path);
case "path-desc":
return b.path.localeCompare(a.path);
case "created-recent":
return bCreated - aCreated;
case "created-oldest":
return aCreated - bCreated;
case "modified-recent":
return bModified - aModified;
case "modified-oldest":
return aModified - bModified;
case "name-asc":
default:
return a.name.localeCompare(b.name);
}
});
// Filter by query
if (!query) {
return filtered.slice(0, 50); // Limit initial results
return sorted.slice(0, 50); // Limit initial results
}
return filtered
return sorted
.filter((file) => {
const searchText = this.getSearchText(file).toLowerCase();
return searchText.includes(lowerQuery);
@ -338,6 +375,15 @@ export function openFileSelector(
title?: string;
filter?: "markdown" | "all" | ((file: TAbstractFile) => boolean);
newFileFolder?: string;
sortOrder?:
| "name-asc"
| "name-desc"
| "path-asc"
| "path-desc"
| "created-recent"
| "created-oldest"
| "modified-recent"
| "modified-oldest";
}
): void {
const modal = new FileSelectorModal(plugin.app, plugin, {
@ -345,6 +391,7 @@ export function openFileSelector(
title: options?.title,
filter: options?.filter,
newFileFolder: options?.newFileFolder,
sortOrder: options?.sortOrder,
onResult: (result) => {
if (result.type === "selected" || result.type === "created") {
onChoose(result.file);

View file

@ -4,14 +4,16 @@ import {
Setting,
Notice,
TAbstractFile,
TFile,
parseYaml,
stringifyYaml,
setTooltip,
} from "obsidian";
import TaskNotesPlugin from "../main";
import { TimeBlock, DailyNoteFrontmatter } from "../types";
import { TimeBlock, DailyNoteFrontmatter, TaskInfo } from "../types";
import { generateTimeblockId } from "../utils/helpers";
import { openFileSelector } from "./FileSelectorModal";
import { openTaskSelector } from "./TaskSelectorWithCreateModal";
import { parseDateAsLocal } from "../utils/dateUtils";
import {
createDailyNote,
@ -26,6 +28,7 @@ export interface TimeblockCreationOptions {
startTime?: string; // HH:MM format
endTime?: string; // HH:MM format
prefilledTitle?: string;
prefilledAttachmentPaths?: string[];
}
export class TimeblockCreationModal extends Modal {
@ -153,12 +156,24 @@ export class TimeblockCreationModal extends Modal {
}, {
placeholder: "Search files or type to create new...",
filter: "all",
sortOrder:
this.plugin.settings.calendarViewSettings
.timeblockAttachmentSearchOrder,
});
});
})
.addButton((button) => {
button
.setButtonText("Add task")
.setTooltip("Select task")
.onClick(() => {
void this.openTaskSelectorForTitle();
});
});
// Attachments list container
this.attachmentsList = contentEl.createDiv({ cls: "timeblock-attachments-list" });
this.initializePrefilledAttachments();
this.renderAttachmentsList(); // Initialize empty state
// Buttons
@ -211,6 +226,21 @@ export class TimeblockCreationModal extends Modal {
createButton.style.opacity = isValid ? "1" : "0.5";
}
private initializePrefilledAttachments(): void {
const prefilledPaths = this.options.prefilledAttachmentPaths || [];
if (prefilledPaths.length === 0) {
return;
}
const uniquePaths = new Set(prefilledPaths.filter((path) => typeof path === "string" && path.trim().length > 0));
for (const path of uniquePaths) {
const file = this.app.vault.getAbstractFileByPath(path);
if (file) {
this.selectedAttachments.push(file);
}
}
}
private async handleSubmit(): Promise<void> {
try {
// Validate inputs
@ -343,11 +373,47 @@ export class TimeblockCreationModal extends Modal {
return;
}
// If title is empty, default it to the selected attachment name.
if (this.titleInput && !this.titleInput.value.trim()) {
const derivedTitle = file instanceof TFile ? file.basename : file.name;
this.titleInput.value = derivedTitle;
this.validateForm();
}
this.selectedAttachments.push(file);
this.renderAttachmentsList();
new Notice(this.translate("notices.timeblockAttachmentAdded", { fileName: file.name }));
}
private async openTaskSelectorForTitle(): Promise<void> {
try {
const allTasks: TaskInfo[] = (await this.plugin.cacheManager.getAllTasks?.()) ?? [];
const candidates = allTasks.filter((task) => !task.archived);
if (candidates.length === 0) {
new Notice("No tasks available to select");
return;
}
openTaskSelector(this.plugin, candidates, (selectedTask) => {
if (!selectedTask) return;
this.titleInput.value = selectedTask.title || "";
this.validateForm();
const taskFile = this.app.vault.getAbstractFileByPath(selectedTask.path);
if (taskFile) {
this.addAttachment(taskFile);
}
}, {
title: "Select task",
});
} catch (error) {
console.error("Failed to open task selector for timeblock creation:", error);
new Notice("Failed to open task selector");
}
}
private removeAttachment(file: TAbstractFile): void {
this.selectedAttachments = this.selectedAttachments.filter(
(existing) => existing.path !== file.path

View file

@ -11,6 +11,7 @@ import {
} from "obsidian";
import TaskNotesPlugin from "../main";
import { openFileSelector } from "./FileSelectorModal";
import { openTaskSelector } from "./TaskSelectorWithCreateModal";
import {
getDailyNote,
getAllDailyNotes,
@ -18,6 +19,7 @@ import {
} from "obsidian-daily-notes-interface";
import { formatDateForStorage } from "../utils/dateUtils";
import { TranslationKey } from "../i18n";
import { TaskInfo } from "../types";
export interface TimeBlock {
title: string;
@ -140,8 +142,19 @@ export class TimeblockInfoModal extends Modal {
}, {
placeholder: "Search files or type to create new...",
filter: "all",
sortOrder:
this.plugin.settings.calendarViewSettings
.timeblockAttachmentSearchOrder,
});
});
})
.addButton((button) => {
button
.setButtonText("Add task")
.setTooltip("Select task")
.onClick(() => {
void this.openTaskSelectorForTitle();
});
});
// Attachments list container
@ -220,11 +233,49 @@ export class TimeblockInfoModal extends Modal {
return;
}
// If title is empty, default it to the first selected attachment name.
if (this.titleInput && !this.titleInput.value.trim()) {
const derivedTitle = file instanceof TFile ? file.basename : file.name;
this.titleInput.value = derivedTitle;
this.timeblock.title = derivedTitle;
this.validateForm();
}
this.selectedAttachments.push(file);
this.renderAttachmentsList();
new Notice(this.translate("notices.timeblockAttachmentAdded", { fileName: file.name }));
}
private async openTaskSelectorForTitle(): Promise<void> {
try {
const allTasks: TaskInfo[] = (await this.plugin.cacheManager.getAllTasks?.()) ?? [];
const candidates = allTasks.filter((task) => !task.archived);
if (candidates.length === 0) {
new Notice("No tasks available to select");
return;
}
openTaskSelector(this.plugin, candidates, (selectedTask) => {
if (!selectedTask) return;
this.titleInput.value = selectedTask.title || "";
this.timeblock.title = selectedTask.title || "";
this.validateForm();
const taskFile = this.app.vault.getAbstractFileByPath(selectedTask.path);
if (taskFile) {
this.addAttachment(taskFile);
}
}, {
title: "Select task",
});
} catch (error) {
console.error("Failed to open task selector for timeblock edit:", error);
new Notice("Failed to open task selector");
}
}
private removeAttachment(file: TAbstractFile): void {
this.selectedAttachments = this.selectedAttachments.filter(
(existing) => existing.path !== file.path

View file

@ -169,6 +169,7 @@ export const DEFAULT_CALENDAR_VIEW_SETTINGS: CalendarViewSettings = {
enableTimeblocking: false, // Disabled by default - toggleable feature
defaultShowTimeblocks: true,
defaultTimeblockColor: "#6366f1",
timeblockAttachmentSearchOrder: "name-asc",
// Calendar behavior
nowIndicator: true,
selectMirror: true,

View file

@ -601,6 +601,38 @@ export function renderFeaturesTab(
);
if (plugin.settings.calendarViewSettings.enableTimeblocking) {
group.addSetting((setting) =>
configureDropdownSetting(setting, {
name: "Attachment Search Order",
desc: "Controls how files are ordered in the Add Attachment search window for timeblocks.",
options: [
{ value: "name-asc", label: "Name (A to Z)" },
{ value: "name-desc", label: "Name (Z to A)" },
{ value: "path-asc", label: "Path (A to Z)" },
{ value: "path-desc", label: "Path (Z to A)" },
{ value: "created-recent", label: "Created (Newest first)" },
{ value: "created-oldest", label: "Created (Oldest first)" },
{ value: "modified-recent", label: "Modified (Newest first)" },
{ value: "modified-oldest", label: "Modified (Oldest first)" },
],
getValue: () =>
plugin.settings.calendarViewSettings.timeblockAttachmentSearchOrder,
setValue: async (value: string) => {
plugin.settings.calendarViewSettings.timeblockAttachmentSearchOrder =
value as
| "name-asc"
| "name-desc"
| "path-asc"
| "path-desc"
| "created-recent"
| "created-oldest"
| "modified-recent"
| "modified-oldest";
save();
},
})
);
group.addSetting((setting) =>
configureToggleSetting(setting, {
name: translate("settings.features.timeblocking.showBlocksName"),

View file

@ -311,6 +311,16 @@ export interface GoogleCalendarExportSettings {
defaultReminderMinutes: number | null; // Popup reminder X minutes before event (null = no reminder)
}
export type TimeblockAttachmentSearchOrder =
| "name-asc"
| "name-desc"
| "path-asc"
| "path-desc"
| "created-recent"
| "created-oldest"
| "modified-recent"
| "modified-oldest";
export interface CalendarViewSettings {
// Default view
defaultView:
@ -345,6 +355,7 @@ export interface CalendarViewSettings {
enableTimeblocking: boolean;
defaultShowTimeblocks: boolean;
defaultTimeblockColor: string;
timeblockAttachmentSearchOrder: TimeblockAttachmentSearchOrder;
// Calendar behavior
nowIndicator: boolean;
selectMirror: boolean;

View file

@ -0,0 +1,54 @@
import { TaskInfo } from "../types";
import {
formatDateForStorage,
hasTimeComponent,
parseDateToLocal,
} from "./dateUtils";
export interface TimeblockPrefill {
date: string;
startTime: string;
endTime: string;
}
function formatTimeForInput(date: Date): string {
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${hours}:${minutes}`;
}
export function buildTimeblockPrefillForTask(
task: TaskInfo,
targetDate: Date
): TimeblockPrefill {
let startDate: Date | null = null;
if (task.scheduled) {
try {
startDate = parseDateToLocal(task.scheduled);
} catch {
startDate = null;
}
}
if (!startDate || isNaN(startDate.getTime())) {
startDate = new Date(targetDate);
}
if (isNaN(startDate.getTime())) {
startDate = new Date();
}
if (!task.scheduled || !hasTimeComponent(task.scheduled)) {
startDate.setHours(9, 0, 0, 0);
}
const durationMinutes = task.timeEstimate && task.timeEstimate > 0 ? task.timeEstimate : 60;
const endDate = new Date(startDate.getTime() + durationMinutes * 60 * 1000);
return {
date: formatDateForStorage(startDate),
startTime: formatTimeForInput(startDate),
endTime: formatTimeForInput(endDate),
};
}

View file

@ -0,0 +1,67 @@
import { describe, expect, it } from "@jest/globals";
import { TaskInfo } from "../../../src/types";
import {
formatDateForStorage,
parseDateToLocal,
} from "../../../src/utils/dateUtils";
import { buildTimeblockPrefillForTask } from "../../../src/utils/timeblockPrefillUtils";
function makeTask(overrides: Partial<TaskInfo> = {}): TaskInfo {
return {
path: "TaskNotes/Tasks/example.md",
title: "Example task",
status: "open",
priority: "normal",
...overrides,
};
}
describe("buildTimeblockPrefillForTask", () => {
it("uses the scheduled date for date-only scheduled tasks", () => {
const prefill = buildTimeblockPrefillForTask(
makeTask({
scheduled: "2026-03-10",
timeEstimate: 30,
}),
new Date(2026, 2, 29, 12, 0, 0, 0)
);
expect(prefill).toEqual({
date: formatDateForStorage(parseDateToLocal("2026-03-10")),
startTime: "09:00",
endTime: "09:30",
});
});
it("uses the scheduled datetime for time-aware scheduled tasks", () => {
const prefill = buildTimeblockPrefillForTask(
makeTask({
scheduled: "2026-03-10T14:15",
timeEstimate: 45,
}),
new Date(2026, 2, 29, 12, 0, 0, 0)
);
expect(prefill).toEqual({
date: formatDateForStorage(parseDateToLocal("2026-03-10T14:15")),
startTime: "14:15",
endTime: "15:00",
});
});
it("falls back to the target date for unscheduled tasks", () => {
const prefill = buildTimeblockPrefillForTask(
makeTask(),
new Date(2026, 2, 29, 12, 0, 0, 0)
);
const expectedFallbackDate = new Date(2026, 2, 29, 12, 0, 0, 0);
expectedFallbackDate.setHours(9, 0, 0, 0);
expect(prefill).toEqual({
date: formatDateForStorage(expectedFallbackDate),
startTime: "09:00",
endTime: "10:00",
});
});
});