add custom date field menus and picker support

This commit is contained in:
callumalpass 2026-06-22 12:15:01 +10:00
parent 81973b9ce7
commit 2b63db9e1a
17 changed files with 491 additions and 30 deletions

View file

@ -12,6 +12,7 @@ import {
import { renameVaultFile } from "../services/VaultMutationService";
import { showConfirmationModal } from "../modals/ConfirmationModal";
import { DateContextMenu } from "./DateContextMenu";
import { DateTimePickerModal } from "../modals/DateTimePickerModal";
import {
buildWeekdaysOnlyRecurrenceRule,
getPluginCalendarLocale,
@ -44,6 +45,7 @@ import {
openOrCreateOccurrenceNote,
} from "../ui/occurrenceNoteActions";
import { createTaskNotesLogger } from "../utils/tasknotesLogger";
import type { UserMappedField } from "../types/settings";
const tasknotesLogger = createTaskNotesLogger({ tag: "Components/TaskContextMenu" });
@ -266,6 +268,8 @@ export class TaskContextMenu {
);
});
this.addCustomDateFieldMenuItems(task, plugin);
if (task.recurrence) {
this.addRecurringInstanceMenuItems(task, plugin);
}
@ -1757,7 +1761,8 @@ export class TaskContextMenu {
submenu: Menu,
currentValue: string | undefined,
onSelect: (value: string | null) => Promise<void>,
onCustomDate: () => void
onCustomDate: () => void,
options: { pickDateTitle?: string } = {}
): void {
const dateContextMenu = new DateContextMenu({
currentValue: currentValue,
@ -1826,7 +1831,7 @@ export class TaskContextMenu {
submenu.addSeparator();
submenu.addItem((item) => {
item.setTitle(this.t("contextMenus.date.pickDateTime"));
item.setTitle(options.pickDateTitle ?? this.t("contextMenus.date.pickDateTime"));
item.setIcon("calendar");
item.onClick(onCustomDate);
});
@ -1842,6 +1847,113 @@ export class TaskContextMenu {
}
}
private addCustomDateFieldMenuItems(task: TaskInfo, plugin: TaskNotesPlugin): void {
const dateFields = this.getCustomDateFields(plugin);
if (dateFields.length === 0) {
return;
}
this.menu.addItem((item) => {
item.setTitle(this.t("contextMenus.task.customDates"));
item.setIcon("calendar-days");
const submenu = getSubmenu(item);
dateFields.forEach((field) => {
submenu.addItem((fieldItem) => {
const fieldLabel = this.getCustomFieldLabel(field);
fieldItem.setTitle(fieldLabel);
fieldItem.setIcon("calendar");
const fieldSubmenu = getSubmenu(fieldItem);
const currentValue = this.getCustomDateFieldValue(task, field);
this.addDateOptions(
fieldSubmenu,
currentValue,
async (value) => {
await this.updateCustomDateField(task, plugin, field, value);
},
() => {
this.openCustomDateFieldPicker(task, plugin, field, currentValue);
},
{
pickDateTitle: this.t("modals.task.userFields.pickDate", {
field: fieldLabel,
}),
}
);
});
});
});
}
private getCustomDateFields(plugin: TaskNotesPlugin): UserMappedField[] {
return (plugin.settings.userFields || []).filter(
(field) => field.type === "date" && field.key.trim().length > 0
);
}
private getCustomFieldLabel(field: UserMappedField): string {
return field.displayName.trim() || field.key || field.id;
}
private getCustomDateFieldValue(task: TaskInfo, field: UserMappedField): string | undefined {
const taskRecord = task as unknown as Record<string, unknown>;
const value = taskRecord[field.key] ?? task.customProperties?.[field.key];
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
private openCustomDateFieldPicker(
task: TaskInfo,
plugin: TaskNotesPlugin,
field: UserMappedField,
currentValue: string | undefined
): void {
this.menu.hide();
const fieldLabel = this.getCustomFieldLabel(field);
const modal = new DateTimePickerModal(plugin.app, {
currentDate: currentValue || null,
title: this.t("modals.task.userFields.pickDate", { field: fieldLabel }),
showTime: false,
plugin,
onSelect: (date) => {
void this.updateCustomDateField(task, plugin, field, date);
},
});
modal.open();
}
private async updateCustomDateField(
task: TaskInfo,
plugin: TaskNotesPlugin,
field: UserMappedField,
value: string | null
): Promise<void> {
const fieldLabel = this.getCustomFieldLabel(field);
try {
const updatedTask = await plugin.updateTaskProperty(
task,
field.key as keyof TaskInfo,
value || undefined
);
Object.assign(task, updatedTask);
this.options.onUpdate?.();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
tasknotesLogger.error("Error updating custom date field:", {
category: "persistence",
operation: "updating-custom-date-field",
details: { taskPath: task.path, field: field.key },
error: errorMessage,
});
new Notice(
this.t("contextMenus.task.notices.updateCustomDateFailure", {
field: fieldLabel,
message: errorMessage,
})
);
}
}
private addRecurrenceOptions(
submenu: Menu,
currentValue: string | undefined,

View file

@ -2594,6 +2594,8 @@ export const de: TranslationTree = {
},
metadata: {
totalTrackedTime: "Gesamte erfasste Zeit:",
due: "Fällig:",
scheduled: "Geplant:",
created: "Erstellt:",
modified: "Geändert:",
file: "Datei:"
@ -2771,6 +2773,7 @@ export const de: TranslationTree = {
prioritySelected: "✓ {label}",
dueDate: "Fälligkeitsdatum",
scheduledDate: "Planungsdatum",
customDates: "Benutzerdefinierte Datumsfelder",
reminders: "Erinnerungen",
remindBeforeDue: "Vor Fälligkeit erinnern…",
remindBeforeScheduled: "Vor Planung erinnern…",
@ -2878,6 +2881,7 @@ export const de: TranslationTree = {
toggleSkipFailure: "Überspringen der wiederkehrenden Aufgabe konnte nicht umgeschaltet werden: {message}",
updateDueDateFailure: "Aufgaben-Fälligkeitsdatum konnte nicht aktualisiert werden: {message}",
updateScheduledFailure: "Aufgaben-Planungsdatum konnte nicht aktualisiert werden: {message}",
updateCustomDateFailure: "Fehler beim Aktualisieren von {field}: {message}",
updateRemindersFailure: "Erinnerungen konnten nicht aktualisiert werden",
clearRemindersFailure: "Erinnerungen konnten nicht gelöscht werden",
addReminderFailure: "Erinnerung konnte nicht hinzugefügt werden",

View file

@ -2753,6 +2753,8 @@ export const en: TranslationTree = {
},
metadata: {
totalTrackedTime: "Total tracked time:",
due: "Due:",
scheduled: "Scheduled:",
created: "Created:",
modified: "Modified:",
file: "File:",
@ -2940,6 +2942,7 @@ export const en: TranslationTree = {
clearTags: "Clear tags",
dueDate: "Due date",
scheduledDate: "Scheduled date",
customDates: "Custom dates",
reminders: "Reminders",
remindBeforeDue: "Remind before due…",
remindBeforeScheduled: "Remind before scheduled…",
@ -3047,6 +3050,7 @@ export const en: TranslationTree = {
toggleSkipFailure: "Failed to toggle recurring task skip: {message}",
updateDueDateFailure: "Failed to update task due date: {message}",
updateScheduledFailure: "Failed to update task scheduled date: {message}",
updateCustomDateFailure: "Failed to update {field}: {message}",
updateRemindersFailure: "Failed to update reminders",
clearRemindersFailure: "Failed to clear reminders",
addReminderFailure: "Failed to add reminder",

View file

@ -2594,6 +2594,8 @@ export const es: TranslationTree = {
},
metadata: {
totalTrackedTime: "Tiempo total rastreado:",
due: "Vencimiento:",
scheduled: "Programada:",
created: "Creado:",
modified: "Modificado:",
file: "Archivo:"
@ -2771,6 +2773,7 @@ export const es: TranslationTree = {
prioritySelected: "✓ {label}",
dueDate: "Fecha de vencimiento",
scheduledDate: "Fecha programada",
customDates: "Fechas personalizadas",
reminders: "Recordatorios",
remindBeforeDue: "Recordar antes del vencimiento…",
remindBeforeScheduled: "Recordar antes de programado…",
@ -2878,6 +2881,7 @@ export const es: TranslationTree = {
toggleSkipFailure: "Error al alternar omisión de tarea recurrente: {message}",
updateDueDateFailure: "Error al actualizar fecha de vencimiento de tarea: {message}",
updateScheduledFailure: "Error al actualizar fecha programada de tarea: {message}",
updateCustomDateFailure: "No se pudo actualizar {field}: {message}",
updateRemindersFailure: "Error al actualizar recordatorios",
clearRemindersFailure: "Error al limpiar recordatorios",
addReminderFailure: "Error al agregar recordatorio",

View file

@ -2594,6 +2594,8 @@ export const fr: TranslationTree = {
},
metadata: {
totalTrackedTime: "Temps suivi total :",
due: "Échéance :",
scheduled: "Planifiée :",
created: "Créée :",
modified: "Modifiée :",
file: "Fichier :"
@ -2771,6 +2773,7 @@ export const fr: TranslationTree = {
prioritySelected: "Priorité sélectionnée : {label}",
dueDate: "Échéance",
scheduledDate: "Date planifiée",
customDates: "Dates personnalisées",
reminders: "Rappels",
remindBeforeDue: "Rappeler avant l'échéance…",
remindBeforeScheduled: "Rappeler avant la date planifiée…",
@ -2878,6 +2881,7 @@ export const fr: TranslationTree = {
toggleSkipFailure: "Impossible de modifier l'omission de tâche récurrente : {message}",
updateDueDateFailure: "Impossible de mettre à jour l'échéance : {message}",
updateScheduledFailure: "Impossible de mettre à jour la date planifiée : {message}",
updateCustomDateFailure: "Échec de la mise à jour de {field} : {message}",
updateRemindersFailure: "Impossible de mettre à jour les rappels",
clearRemindersFailure: "Impossible de supprimer les rappels",
addReminderFailure: "Impossible d'ajouter un rappel",

View file

@ -2594,6 +2594,8 @@ export const ja: TranslationTree = {
},
metadata: {
totalTrackedTime: "総追跡時間:",
due: "期限:",
scheduled: "予定:",
created: "作成:",
modified: "変更:",
file: "ファイル:"
@ -2771,6 +2773,7 @@ export const ja: TranslationTree = {
prioritySelected: "✓ {label}",
dueDate: "期限日",
scheduledDate: "予定日",
customDates: "カスタム日付",
reminders: "リマインダー",
remindBeforeDue: "期限前にリマインド…",
remindBeforeScheduled: "予定前にリマインド…",
@ -2878,6 +2881,7 @@ export const ja: TranslationTree = {
toggleSkipFailure: "繰り返しタスクのスキップ切り替えに失敗しました:{message}",
updateDueDateFailure: "タスク期限日の更新に失敗しました:{message}",
updateScheduledFailure: "タスク予定日の更新に失敗しました:{message}",
updateCustomDateFailure: "{field}の更新に失敗しました:{message}",
updateRemindersFailure: "リマインダーの更新に失敗しました",
clearRemindersFailure: "リマインダーのクリアに失敗しました",
addReminderFailure: "リマインダーの追加に失敗しました",

View file

@ -2755,6 +2755,7 @@ export const ko: TranslationTree = {
prioritySelected: "✓ {label}",
dueDate: "마감일",
scheduledDate: "예정일",
customDates: "사용자 지정 날짜",
reminders: "리마인더",
remindBeforeDue: "마감 전 알림...",
remindBeforeScheduled: "예정 전 알림...",
@ -2862,6 +2863,7 @@ export const ko: TranslationTree = {
toggleSkipFailure: "반복 작업 건너뛰기 토글 실패: {message}",
updateDueDateFailure: "작업 마감일 업데이트 실패: {message}",
updateScheduledFailure: "작업 예정일 업데이트 실패: {message}",
updateCustomDateFailure: "{field} 업데이트 실패: {message}",
updateRemindersFailure: "리마인더 업데이트 실패",
clearRemindersFailure: "리마인더 지우기 실패",
addReminderFailure: "리마인더 추가 실패",

View file

@ -2596,6 +2596,8 @@ export const pt: TranslationTree = {
},
metadata: {
totalTrackedTime: "Tempo total registrado:",
due: "Vencimento:",
scheduled: "Agendada:",
created: "Criada:",
modified: "Modificada:",
file: "Arquivo:"
@ -2773,6 +2775,7 @@ export const pt: TranslationTree = {
prioritySelected: "✓ {label}",
dueDate: "Data de vencimento",
scheduledDate: "Data agendada",
customDates: "Datas personalizadas",
reminders: "Lembretes",
remindBeforeDue: "Lembrar antes do vencimento…",
remindBeforeScheduled: "Lembrar antes da data agendada…",
@ -2880,6 +2883,7 @@ export const pt: TranslationTree = {
toggleSkipFailure: "Falha ao alternar pulo de tarefa recorrente: {message}",
updateDueDateFailure: "Falha ao atualizar data de vencimento da tarefa: {message}",
updateScheduledFailure: "Falha ao atualizar data agendada da tarefa: {message}",
updateCustomDateFailure: "Falha ao atualizar {field}: {message}",
updateRemindersFailure: "Falha ao atualizar lembretes",
clearRemindersFailure: "Falha ao limpar lembretes",
addReminderFailure: "Falha ao adicionar lembrete",

View file

@ -2594,6 +2594,8 @@ export const ru: TranslationTree = {
},
metadata: {
totalTrackedTime: "Общее отслеженное время:",
due: "Срок:",
scheduled: "Запланировано:",
created: "Создано:",
modified: "Изменено:",
file: "Файл:"
@ -2771,6 +2773,7 @@ export const ru: TranslationTree = {
prioritySelected: "✓ {label}",
dueDate: "Срок выполнения",
scheduledDate: "Запланированная дата",
customDates: "Пользовательские даты",
reminders: "Напоминания",
remindBeforeDue: "Напомнить до срока выполнения…",
remindBeforeScheduled: "Напомнить до запланированной даты…",
@ -2878,6 +2881,7 @@ export const ru: TranslationTree = {
toggleSkipFailure: "Не удалось переключить пропуск повторяющейся задачи: {message}",
updateDueDateFailure: "Не удалось обновить срок выполнения задачи: {message}",
updateScheduledFailure: "Не удалось обновить запланированную дату задачи: {message}",
updateCustomDateFailure: "Не удалось обновить {field}: {message}",
updateRemindersFailure: "Не удалось обновить напоминания",
clearRemindersFailure: "Не удалось очистить напоминания",
addReminderFailure: "Не удалось добавить напоминание",

View file

@ -2594,6 +2594,8 @@ export const zh: TranslationTree = {
},
metadata: {
totalTrackedTime: "总跟踪时间:",
due: "截止:",
scheduled: "计划:",
created: "创建:",
modified: "修改:",
file: "文件:"
@ -2770,6 +2772,7 @@ export const zh: TranslationTree = {
prioritySelected: "✓ {label}",
dueDate: "到期日期",
scheduledDate: "安排日期",
customDates: "自定义日期",
reminders: "提醒",
remindBeforeDue: "到期前提醒...",
remindBeforeScheduled: "安排前提醒...",
@ -2877,6 +2880,7 @@ export const zh: TranslationTree = {
toggleSkipFailure: "切换重复任务跳过失败:{message}",
updateDueDateFailure: "更新任务到期日期失败:{message}",
updateScheduledFailure: "更新任务安排日期失败:{message}",
updateCustomDateFailure: "更新 {field} 失败:{message}",
updateRemindersFailure: "更新提醒失败",
clearRemindersFailure: "清除提醒失败",
addReminderFailure: "添加提醒失败",

View file

@ -11,6 +11,7 @@ export interface DateTimePickerOptions {
currentTime?: string | null;
title?: string;
dateRole?: "due" | "scheduled";
showTime?: boolean;
plugin?: TaskNotesPlugin;
naturalLanguageParser?: NaturalLanguageDateParser;
onSelect: (date: string | null, time: string | null) => void;
@ -114,7 +115,9 @@ export class DateTimePickerModal extends Modal {
this.renderQuickActions(contentEl);
this.renderNaturalLanguageInput(contentEl);
this.renderDateInput(contentEl);
this.renderTimeInput(contentEl);
if (this.shouldShowTime()) {
this.renderTimeInput(contentEl);
}
this.renderActions(contentEl);
this.updateSelectButtonState();
@ -300,7 +303,10 @@ export class DateTimePickerModal extends Modal {
private confirmSelectedDate(): void {
if (!this.selectedDate) return;
this.options.onSelect(this.selectedDate, this.timeInput?.value || null);
this.options.onSelect(
this.selectedDate,
this.shouldShowTime() ? this.timeInput?.value || null : null
);
this.close();
}
@ -314,6 +320,10 @@ export class DateTimePickerModal extends Modal {
return Boolean(this.options.plugin?.settings.enableNaturalLanguageInput);
}
private shouldShowTime(): boolean {
return this.options.showTime !== false;
}
private getNaturalLanguageParser(): NaturalLanguageDateParser | null {
if (this.options.naturalLanguageParser) return this.options.naturalLanguageParser;
if (!this.options.plugin?.settings.enableNaturalLanguageInput) return null;
@ -332,7 +342,7 @@ export class DateTimePickerModal extends Modal {
return;
}
this.options.onSelect(selection.date, selection.time);
this.options.onSelect(selection.date, this.shouldShowTime() ? selection.time : null);
this.close();
}
}

View file

@ -1,6 +1,6 @@
import { Setting, setIcon, type App, type TextComponent } from "obsidian";
import { DateContextMenu } from "../components/DateContextMenu";
import type TaskNotesPlugin from "../main";
import { DateTimePickerModal } from "./DateTimePickerModal";
import type { UserMappedField } from "../types/settings";
import { attachDateInputBehavior } from "../ui/dateInputBehavior";
import { stringifyUnknown } from "../utils/stringUtils";
@ -266,16 +266,19 @@ function createTaskModalDateUserField(
setIcon(button, "calendar");
button.addEventListener("click", (event) => {
event.preventDefault();
const menu = new DateContextMenu({
currentValue: getTextValue(text) || undefined,
const picker = new DateTimePickerModal(context.app, {
currentDate: getTextValue(text) || null,
title: context.translate("modals.task.userFields.pickDate", {
field: field.displayName,
}),
showTime: false,
plugin: context.plugin,
onSelect: (value) => {
text.setValue(value || "");
options.onValueChange(field.key, parseNullableTextUserFieldInput(value));
},
plugin: context.plugin,
app: context.app,
});
menu.showAtElement(button);
picker.open();
});
}
});

View file

@ -230,22 +230,32 @@ export class NaturalLanguageParser extends NaturalLanguageParserCore {
const userFields = parsed.userFields as Record<string, unknown>;
for (const userField of this.taskNotesUserFields) {
if (userField.type !== "boolean") continue;
const value = userFields[userField.id];
if (typeof value !== "string") continue;
const normalized = value.trim().toLowerCase();
if (normalized === "true") {
userFields[userField.id] = true;
} else if (normalized === "false") {
userFields[userField.id] = false;
if (userField.type === "boolean") {
const normalized = value.trim().toLowerCase();
if (normalized === "true") {
userFields[userField.id] = true;
} else if (normalized === "false") {
userFields[userField.id] = false;
}
} else if (userField.type === "date") {
userFields[userField.id] = this.normalizeDateUserFieldValue(value) ?? value;
}
}
return parsed;
}
private normalizeDateUserFieldValue(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = super.parseInput(trimmed);
return parsed.scheduledDate || parsed.dueDate || null;
}
private extractLinkedUserFields(input: string, parsed: ParsedTaskData): ParsedTaskData {
const triggers = this.taskNotesNlpTriggers?.triggers || [];
if (triggers.length === 0 || this.taskNotesUserFields.length === 0) {

View file

@ -100,4 +100,35 @@ describe("Issue #1462: NLP date edits", () => {
expect(onSelect).toHaveBeenCalledWith("2026-01-23", "08:00");
});
it("can run as a date-only picker for custom date fields", () => {
const onSelect = jest.fn();
const parser = {
parseInput: jest.fn(() =>
parsedTaskData({
scheduledDate: "2026-01-24",
scheduledTime: "09:30",
})
),
};
const modal = new DateTimePickerModal({} as any, {
showTime: false,
naturalLanguageParser: parser,
onSelect,
});
modal.open();
expect(modal.contentEl.querySelector(".date-time-picker-modal__time-field")).toBeNull();
const input = modal.contentEl.querySelector<HTMLInputElement>(
".date-time-picker-modal__nlp-input"
);
expect(input).toBeTruthy();
input!.value = "next Saturday at 9:30am";
input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(onSelect).toHaveBeenCalledWith("2026-01-24", null);
});
});

View file

@ -0,0 +1,62 @@
import type TaskNotesPlugin from "../../../src/main";
import { NaturalLanguageParser } from "../../../src/services/NaturalLanguageParser";
import { buildTaskCreationDataFromParsed } from "../../../src/services/buildTaskCreationDataFromParsed";
import { DEFAULT_SETTINGS } from "../../../src/settings/defaults";
import type { NLPTriggersConfig, UserMappedField } from "../../../src/types/settings";
const DATE_FIELD: UserMappedField = {
id: "snoozed",
displayName: "Snoozed",
key: "snoozed",
type: "date",
};
const NLP_TRIGGERS: NLPTriggersConfig = {
triggers: [{ propertyId: "snoozed", trigger: "s:", enabled: true }],
};
function formatDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function tomorrowDate(): string {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
return formatDate(tomorrow);
}
function createParser(): NaturalLanguageParser {
return new NaturalLanguageParser([], [], true, "en", NLP_TRIGGERS, [DATE_FIELD]);
}
function createPlugin(): TaskNotesPlugin {
return {
settings: {
...DEFAULT_SETTINGS,
userFields: [DATE_FIELD],
},
} as unknown as TaskNotesPlugin;
}
describe("Issue #2058: NLP date user fields", () => {
it("parses natural-language values for date-type custom fields", () => {
const parsed = createParser().parseInput("Review invoices s:tomorrow");
expect(parsed.title).toBe("Review invoices");
expect((parsed.userFields as Record<string, unknown>).snoozed).toBe(tomorrowDate());
const taskData = buildTaskCreationDataFromParsed(createPlugin(), parsed);
expect(taskData.customFrontmatter).toEqual({ snoozed: tomorrowDate() });
});
it("leaves unrecognized date custom field values untouched", () => {
const parsed = createParser().parseInput("Review invoices s:later");
expect(parsed.title).toBe("Review invoices");
expect(parsed.userFields).toEqual({ snoozed: "later" });
});
});

View file

@ -0,0 +1,195 @@
import { App, Menu } from "obsidian";
import { TaskContextMenu } from "../../../src/components/TaskContextMenu";
import { DateTimePickerModal } from "../../../src/modals/DateTimePickerModal";
import { createI18nService } from "../../../src/i18n";
import type TaskNotesPlugin from "../../../src/main";
import type { TaskInfo } from "../../../src/types";
jest.mock("../../../src/modals/DateTimePickerModal", () => ({
DateTimePickerModal: jest.fn().mockImplementation(() => ({
open: jest.fn(),
})),
}));
type MockMenuItem = {
setTitle?: jest.Mock;
setIcon?: jest.Mock;
onClick?: jest.Mock;
submenu?: MockMenu;
type?: string;
};
type MockMenu = {
items: MockMenuItem[];
hide: jest.Mock;
};
const menuMock = Menu as unknown as jest.Mock;
const dateTimePickerMock = DateTimePickerModal as unknown as jest.Mock;
function createTask(overrides: Record<string, unknown> = {}): TaskInfo {
return {
id: "Tasks/custom-date-menu.md",
path: "Tasks/custom-date-menu.md",
title: "Custom date menu",
status: "open",
priority: "normal",
archived: false,
tags: [],
contexts: [],
projects: [],
...overrides,
} as unknown as TaskInfo;
}
function createPlugin(): TaskNotesPlugin {
const app = new App();
return {
app,
i18n: createI18nService(),
settings: {
customStatuses: [],
customPriorities: [],
userFields: [
{
id: "snoozed",
key: "snoozed",
displayName: "Snoozed",
type: "date",
},
{
id: "energy",
key: "energy",
displayName: "Energy",
type: "number",
},
],
calendarViewSettings: {
enableTimeblocking: false,
},
useFrontmatterMarkdownLinks: true,
},
statusManager: {
getAllStatuses: jest.fn(() => []),
getNonCompletionStatuses: jest.fn(() => []),
isCompletedStatus: jest.fn(() => false),
},
priorityManager: {
getAllPriorities: jest.fn(() => []),
getPrioritiesByWeight: jest.fn(() => []),
},
taskService: {
toggleRecurringTaskSkipped: jest.fn(),
updateBlockingRelationships: jest.fn(),
deleteTask: jest.fn(),
},
cacheManager: {
getAllContexts: jest.fn(() => []),
getAllTasks: jest.fn(() => []),
getTaskInfo: jest.fn(),
},
updateTaskProperty: jest.fn(async (task, property, value) => ({
...task,
[property]: value,
})),
toggleRecurringTaskComplete: 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(),
openTaskCreationModal: jest.fn(),
} as unknown as TaskNotesPlugin;
}
function getTopLevelMenu(): MockMenu {
return menuMock.mock.results[0].value as MockMenu;
}
function findMenuItemByTitle(menu: MockMenu | undefined, title: string): MockMenuItem | undefined {
return menu?.items.find((item) =>
item.setTitle?.mock.calls.some(([value]) => value === title)
);
}
function createMenuContext(task = createTask({ snoozed: "2026-06-24" })) {
const plugin = createPlugin();
const onUpdate = jest.fn();
new TaskContextMenu({
task,
plugin,
targetDate: new Date("2026-06-21T12:00:00"),
onUpdate,
});
const customDatesItem = findMenuItemByTitle(getTopLevelMenu(), "Custom dates");
const snoozedItem = findMenuItemByTitle(customDatesItem?.submenu, "Snoozed");
return {
plugin,
onUpdate,
topLevelMenu: getTopLevelMenu(),
customDatesItem,
snoozedItem,
snoozedMenu: snoozedItem?.submenu,
};
}
describe("Issue #2060: custom date fields in the task context menu", () => {
beforeEach(() => {
menuMock.mockClear();
dateTimePickerMock.mockClear();
});
afterEach(() => {
menuMock.mockClear();
dateTimePickerMock.mockClear();
});
it("adds date-type user fields to a Custom dates submenu", () => {
const { customDatesItem, snoozedItem, snoozedMenu } = createMenuContext();
expect(customDatesItem).toBeDefined();
expect(customDatesItem?.setIcon).toHaveBeenCalledWith("calendar-days");
expect(snoozedItem).toBeDefined();
expect(findMenuItemByTitle(customDatesItem?.submenu, "Energy")).toBeUndefined();
expect(findMenuItemByTitle(snoozedMenu, "Pick Snoozed date")).toBeDefined();
expect(findMenuItemByTitle(snoozedMenu, "Clear date")).toBeDefined();
});
it("clears custom dates through the configured field key", async () => {
const task = createTask({ snoozed: "2026-06-24" });
const { plugin, onUpdate, snoozedMenu } = createMenuContext(task);
const clearItem = findMenuItemByTitle(snoozedMenu, "Clear date");
clearItem?.onClick?.mock.calls[0][0]();
await Promise.resolve();
await Promise.resolve();
expect(plugin.updateTaskProperty).toHaveBeenCalledWith(task, "snoozed", undefined);
expect(onUpdate).toHaveBeenCalledTimes(1);
});
it("opens the date-only picker for custom date fields", () => {
const { plugin, topLevelMenu, snoozedMenu } = createMenuContext();
const pickItem = findMenuItemByTitle(snoozedMenu, "Pick Snoozed date");
pickItem?.onClick?.mock.calls[0][0]();
expect(topLevelMenu.hide).toHaveBeenCalledTimes(1);
expect(dateTimePickerMock).toHaveBeenCalledWith(
plugin.app,
expect.objectContaining({
currentDate: "2026-06-24",
title: "Pick Snoozed date",
showTime: false,
plugin,
})
);
expect(dateTimePickerMock.mock.results[0].value.open).toHaveBeenCalledTimes(1);
});
});

View file

@ -12,12 +12,14 @@ const mockToggleControls: Array<{
setValue: (value: boolean) => unknown;
}> = [];
const mockDateMenus: Array<{
const mockDateTimePickers: Array<{
options: {
currentValue?: string | null;
onSelect: (value: string | null) => void;
currentDate?: string | null;
showTime?: boolean;
onSelect: (value: string | null, time: string | null) => void;
};
showAtElement: jest.Mock;
open: jest.Mock;
}> = [];
const mockSetIcon = jest.fn();
@ -102,14 +104,14 @@ jest.mock("obsidian", () => ({
setIcon: mockSetIcon,
}));
jest.mock("../../../src/components/DateContextMenu", () => ({
DateContextMenu: jest.fn().mockImplementation((options) => {
const menu = {
jest.mock("../../../src/modals/DateTimePickerModal", () => ({
DateTimePickerModal: jest.fn().mockImplementation((_app, options) => {
const picker = {
options,
showAtElement: jest.fn(),
open: jest.fn(),
};
mockDateMenus.push(menu);
return menu;
mockDateTimePickers.push(picker);
return picker;
}),
}));
@ -154,7 +156,7 @@ describe("taskModalUserFieldControls", () => {
document.body.innerHTML = "";
mockTextControls.length = 0;
mockToggleControls.length = 0;
mockDateMenus.length = 0;
mockDateTimePickers.length = 0;
mockSetIcon.mockClear();
mockUserFieldSuggest.mockClear();
mockAttachDateInputBehavior.mockClear();
@ -306,7 +308,7 @@ describe("taskModalUserFieldControls", () => {
mockTextControls[3].onChangeCallback?.("");
mockToggleControls[0].onChangeCallback?.(false);
dateButton?.click();
mockDateMenus[0].options.onSelect("2026-06-03");
mockDateTimePickers[0].options.onSelect("2026-06-03", null);
expect(onValueChange).toHaveBeenCalledWith("assignee", null);
expect(onValueChange).toHaveBeenCalledWith("labels", ["one", "two"]);
@ -314,7 +316,9 @@ describe("taskModalUserFieldControls", () => {
expect(onValueChange).toHaveBeenCalledWith("custom_date", null);
expect(onValueChange).toHaveBeenCalledWith("custom_date", "2026-06-03");
expect(onValueChange).toHaveBeenCalledWith("flagged", false);
expect(mockDateMenus[0].showAtElement).toHaveBeenCalledWith(dateButton);
expect(mockDateTimePickers[0].options.currentDate).toBe("2026-05-19");
expect(mockDateTimePickers[0].options.showTime).toBe(false);
expect(mockDateTimePickers[0].open).toHaveBeenCalled();
});
it("updates existing user-field input and toggle controls from modal state", () => {