Refactor and consolidate modal implementation, update naming and imports, and adjust styles and tests

• Remove the legacy “MinimalistTaskModal” base class and its derivatives (MinimalistTaskCreationModal and MinimalistTaskEditModal) by renaming and consolidating them into new classes:
  – Rename MinimalistTaskModal to TaskModal
  – Rename MinimalistTaskCreationModal → TaskCreationModal
  – Rename MinimalistTaskEditModal → TaskEditModal
• Update all references and imports across the project (including AdvancedCalendarView) to use the new TaskModal, TaskCreationModal, and TaskEditModal classes instead of the old minimalist versions.
• Extract and expose a new TaskConversionOptions type (in src/types/taskConversion.ts) to provide a consistent interface for passing conversion-related options.
• Adjust the modal logic:
  – In TaskCreationModal, update natural language parsing, pre-populated values, and form handling using the new base TaskModal APIs.
  – In TaskEditModal, change the constructor signature to accept an options object (including “task” and an optional “onTaskUpdated” callback) and update recurrence handling to convert legacy recurrence objects into display strings.
• Update CSS:
  – Rename styles/minimalist-modal.css to styles/task-modal.css and update class comments (e.g. “MINIMALIST TASK MODAL” → “TASK MODAL – Google Keep/Todoist Inspired”).
  – Modify selectors in modal-bem.css by removing references to “.task-creation-modal” and “.task-edit-modal” in some cases, as these are now included under the unified “tasknotes-plugin” context.
• Remove the tests for BaseTaskModal (tests/unit/modals/BaseTaskModal.test.ts) since that base class is no longer used, and update test references for the new TaskCreationModal class.
• Overall, this commit streamlines the modal codebase and unifies the naming and styling of task-related modals while updating the type definitions and downstream usage in views and tests.

These changes are backward‐compatible with previous plugin settings (aside from renamed classes/interfaces) but may require updates in any custom integrations relying on the old MinimalistTask* modals.
This commit is contained in:
Callum Alpass 2025-06-27 21:45:08 +10:00
parent 169449c8b8
commit b4fe29068b
16 changed files with 610 additions and 4424 deletions

View file

@ -234,7 +234,7 @@ Let's say you want to add a `complexity: 'simple' | 'medium' | 'hard'` property
4. **Update `TaskService.ts`**:
* In `createTask`, handle the new property, possibly applying a default value.
* In `updateTask`, ensure the new property can be updated.
5. **Update `BaseTaskModal.ts`**: Add a dropdown or input field to `TaskCreationModal` and `TaskEditModal` to allow users to set the complexity.
5. **Update `TaskModal.ts`**: Add a dropdown or input field to `TaskCreationModal` and `TaskEditModal` to allow users to set the complexity.
6. **Update `TaskCard.ts`**: In `createTaskCard` and `updateTaskCard`, add logic to display the new complexity information (e.g., an icon or text in the metadata line).
7. **Update `FilterService.ts` (Optional)**: If you want to filter by complexity frequently:
* Consider whether an index is needed (probably not - compute on-demand)

View file

@ -13,7 +13,7 @@ const CSS_FILES = [
'styles/note-card-bem.css', // NoteCard component with proper BEM scoping
'styles/filter-bar-bem.css', // FilterBar component with proper BEM scoping
'styles/modal-bem.css', // Modal components with proper BEM scoping
'styles/minimalist-modal.css', // Minimalist modal components (Google Keep/Todoist style)
'styles/task-modal.css', // Task modal components (Google Keep/Todoist style)
'styles/date-picker.css', // Enhanced date/time picker styling
'styles/task-selector-modal.css', // TaskSelectorModal component with proper BEM scoping
'styles/unscheduled-tasks-selector-modal.css', // UnscheduledTasksSelectorModal component with proper BEM scoping
@ -66,221 +66,7 @@ const MAIN_CSS_TEMPLATE = `/* TaskNotes Plugin Styles */
`;
const REMAINING_STYLES = `
/* Task Creation Modal */
.task-creation-modal .input-with-counter {
position: relative;
}
.task-creation-modal .character-counter {
position: absolute;
top: -1.2rem;
right: 0;
font-size: var(--cs-text-xs);
color: var(--text-muted);
font-weight: 500;
}
.task-creation-modal .character-counter.warning {
color: var(--text-error);
}
.task-creation-modal .autocomplete-container {
position: relative;
}
.task-creation-modal .autocomplete-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--background-modifier-border);
border-radius: var(--cs-radius-sm);
background: var(--background-primary);
color: var(--text-normal);
font-size: var(--cs-text-base);
}
.task-creation-modal .autocomplete-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-top: none;
border-radius: 0 0 var(--cs-radius-sm) var(--cs-radius-sm);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1000;
max-height: 200px;
overflow-y: auto;
}
.task-creation-modal .autocomplete-suggestion {
padding: 0.5rem;
cursor: pointer;
border-bottom: 1px solid var(--background-modifier-border-hover);
font-size: var(--cs-text-sm);
color: var(--text-normal);
}
.task-creation-modal .autocomplete-suggestion:hover,
.task-creation-modal .autocomplete-suggestion.selected {
background: var(--background-modifier-hover);
}
.task-creation-modal .autocomplete-suggestion:last-child {
border-bottom: none;
}
.task-creation-modal .form-group {
margin-bottom: 1rem;
position: relative;
}
.task-creation-modal .form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
color: var(--text-normal);
font-size: var(--cs-text-sm);
}
.task-creation-modal input,
.task-creation-modal select,
.task-creation-modal textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--background-modifier-border);
border-radius: var(--cs-radius-sm);
background: var(--background-primary);
color: var(--text-normal);
font-size: var(--cs-text-base);
transition: border-color var(--cs-transition-fast);
}
.task-creation-modal input:focus,
.task-creation-modal select:focus,
.task-creation-modal textarea:focus {
outline: none;
border-color: var(--interactive-accent);
box-shadow: 0 0 0 2px rgba(var(--interactive-accent-rgb), 0.2);
}
.task-creation-modal .button-container {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--background-modifier-border);
}
.task-creation-modal .create-button {
background: var(--interactive-accent);
color: var(--text-on-accent);
border: none;
padding: 0.5rem 1rem;
border-radius: var(--cs-radius-sm);
font-weight: 500;
cursor: pointer;
transition: background-color var(--cs-transition-fast);
}
.task-creation-modal .create-button:hover {
background: var(--interactive-accent-hover);
}
.task-creation-modal .recurrence-helper-text {
font-size: var(--cs-text-xs);
color: var(--text-muted);
margin-top: 0.25rem;
font-style: italic;
}
.task-creation-modal .days-container {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.5rem;
}
.task-creation-modal .day-checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: var(--cs-text-sm);
}
.task-creation-modal .day-checkbox {
width: auto !important;
margin: 0;
}
.task-creation-modal .time-estimate-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.task-creation-modal .time-estimate-container input {
width: 80px;
flex-shrink: 0;
}
.task-creation-modal .time-unit-label {
font-size: var(--cs-text-sm);
color: var(--text-muted);
}
/* Recurrence options styling */
.task-creation-modal .recurrence-pattern-group {
border: 1px solid var(--background-modifier-border);
border-radius: var(--cs-radius-sm);
padding: var(--cs-spacing-sm);
background: var(--background-secondary);
}
/* Days of week selection */
.task-creation-modal .days-of-week-container {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: var(--cs-spacing-xs);
margin-top: var(--cs-spacing-sm);
}
.task-creation-modal .day-button {
padding: var(--cs-spacing-xs) var(--cs-spacing-sm);
border: 1px solid var(--background-modifier-border);
border-radius: var(--cs-radius-sm);
background: var(--background-primary);
color: var(--text-normal);
cursor: pointer;
font-size: var(--cs-text-xs);
text-transform: uppercase;
letter-spacing: 0.05em;
transition: all var(--cs-transition-fast);
text-align: center;
min-height: var(--cs-touch-target);
display: flex;
align-items: center;
justify-content: center;
}
.task-creation-modal .day-button:hover {
background: var(--background-modifier-hover);
border-color: var(--interactive-accent);
}
.task-creation-modal .day-button.selected {
background: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.task-creation-modal .day-button:focus {
outline: 2px solid var(--interactive-accent);
outline-offset: 2px;
}`;
const REMAINING_STYLES = ``;
function buildCSS() {
console.log('Building CSS...');

View file

@ -34,9 +34,9 @@ import { AgendaView } from './views/AgendaView';
import { PomodoroView } from './views/PomodoroView';
import { PomodoroStatsView } from './views/PomodoroStatsView';
import { KanbanView } from './views/KanbanView';
import { TaskCreationModal, TaskConversionOptions } from './modals/TaskCreationModal';
import { MinimalistTaskCreationModal } from './modals/MinimalistTaskCreationModal';
import { MinimalistTaskEditModal } from './modals/MinimalistTaskEditModal';
import { TaskConversionOptions } from './types/taskConversion';
import { TaskCreationModal } from './modals/TaskCreationModal';
import { TaskEditModal } from './modals/TaskEditModal';
import { PomodoroService } from './services/PomodoroService';
import {
formatTime,
@ -951,7 +951,7 @@ private injectCustomStyles(): void {
}
openTaskCreationModal(prePopulatedValues?: Partial<TaskInfo>) {
new MinimalistTaskCreationModal(this.app, this, { prePopulatedValues }).open();
new TaskCreationModal(this.app, this, { prePopulatedValues }).open();
}
@ -1024,7 +1024,7 @@ private injectCustomStyles(): void {
*/
async openTaskEditModal(task: TaskInfo) {
// With native cache, task data is always current - no need to refetch
new MinimalistTaskEditModal(this.app, this, { task }).open();
new TaskEditModal(this.app, this, { task }).open();
}
/**
@ -1114,7 +1114,7 @@ private injectCustomStyles(): void {
prefilledDetails: details
};
// Convert the TaskConversionOptions to the new format expected by MinimalistTaskCreationModal
// Convert the TaskConversionOptions to the new format expected by TaskCreationModal
const prePopulatedValues: Partial<TaskInfo> = {};
if (conversionOptions.parsedData) {
@ -1126,8 +1126,8 @@ private injectCustomStyles(): void {
if (conversionOptions.parsedData.recurrence) prePopulatedValues.recurrence = conversionOptions.parsedData.recurrence;
}
// Open MinimalistTaskCreationModal with pre-populated data and conversion callback
const modal = new MinimalistTaskCreationModal(this.app, this, {
// Open TaskCreationModal with pre-populated data and conversion callback
const modal = new TaskCreationModal(this.app, this, {
prePopulatedValues,
onTaskCreated: (taskInfo: TaskInfo) => {
// Handle the task conversion completion - replace the original task line

File diff suppressed because it is too large Load diff

View file

@ -1,379 +0,0 @@
import { App, Notice, setIcon } from 'obsidian';
import TaskNotesPlugin from '../main';
import { MinimalistTaskModal } from './MinimalistTaskModal';
import { TaskInfo, TaskCreationData } from '../types';
import { getCurrentTimestamp } from '../utils/dateUtils';
import { generateTaskFilename, FilenameContext } from '../utils/filenameGenerator';
import { calculateDefaultDate } from '../utils/helpers';
import { NaturalLanguageParser, ParsedTaskData as NLParsedTaskData } from '../services/NaturalLanguageParser';
export interface TaskCreationOptions {
prePopulatedValues?: Partial<TaskInfo>;
onTaskCreated?: (task: TaskInfo) => void;
}
export class MinimalistTaskCreationModal extends MinimalistTaskModal {
private options: TaskCreationOptions;
private nlParser: NaturalLanguageParser;
private nlInput: HTMLTextAreaElement;
private nlPreviewContainer: HTMLElement;
private nlButtonContainer: HTMLElement;
constructor(app: App, plugin: TaskNotesPlugin, options: TaskCreationOptions = {}) {
super(app, plugin);
this.options = options;
this.nlParser = new NaturalLanguageParser(
plugin.settings.customStatuses,
plugin.settings.customPriorities,
plugin.settings.nlpDefaultToScheduled
);
}
getModalTitle(): string {
return 'Create task';
}
protected createModalContent(): void {
const { contentEl } = this;
contentEl.empty();
// Create main container
const container = contentEl.createDiv('minimalist-modal-container');
// Create NLP input as primary interface (if enabled)
if (this.plugin.settings.enableNaturalLanguageInput) {
this.createNaturalLanguageInput(container);
} else {
// Fall back to regular title input
this.createTitleInput(container);
}
// Create action bar with icons
this.createActionBar(container);
// Create collapsible details section
this.createDetailsSection(container);
// Create save/cancel buttons
this.createActionButtons(container);
}
private createNaturalLanguageInput(container: HTMLElement): void {
const nlContainer = container.createDiv('nl-input-container');
// Create minimalist input field
this.nlInput = nlContainer.createEl('textarea', {
cls: 'nl-input',
attr: {
placeholder: 'Buy groceries tomorrow at 3pm @home #errands\n\nAdd details here...',
rows: '3'
}
});
// Preview container
this.nlPreviewContainer = nlContainer.createDiv('nl-preview-container');
// Event listeners
this.nlInput.addEventListener('input', () => {
const input = this.nlInput.value.trim();
if (input) {
this.updateNaturalLanguagePreview(input);
} else {
this.clearNaturalLanguagePreview();
}
});
// Keyboard shortcuts
this.nlInput.addEventListener('keydown', (e) => {
const input = this.nlInput.value.trim();
if (!input) return;
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.handleSave();
} else if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
this.parseAndFillForm(input);
}
});
// Focus the input
setTimeout(() => {
this.nlInput.focus();
}, 100);
}
private updateNaturalLanguagePreview(input: string): void {
if (!this.nlPreviewContainer) return;
const parsed = this.nlParser.parseInput(input);
const previewData = this.nlParser.getPreviewData(parsed);
if (previewData.length > 0 && parsed.title) {
this.nlPreviewContainer.empty();
this.nlPreviewContainer.style.display = 'block';
previewData.forEach((item) => {
const previewItem = this.nlPreviewContainer.createDiv('nl-preview-item');
previewItem.textContent = item.text;
});
} else {
this.clearNaturalLanguagePreview();
}
}
private clearNaturalLanguagePreview(): void {
if (this.nlPreviewContainer) {
this.nlPreviewContainer.empty();
this.nlPreviewContainer.style.display = 'none';
}
}
protected createActionBar(container: HTMLElement): void {
this.actionBar = container.createDiv('action-bar');
// NLP-specific icons (only if NLP is enabled)
if (this.plugin.settings.enableNaturalLanguageInput) {
// Fill form icon
this.createActionIcon(this.actionBar, 'wand', 'Fill form from natural language', (icon, event) => {
const input = this.nlInput?.value.trim();
if (input) {
this.parseAndFillForm(input);
}
});
// Expand/collapse icon
this.createActionIcon(this.actionBar, this.isExpanded ? 'chevron-up' : 'chevron-down',
this.isExpanded ? 'Hide detailed options' : 'Show detailed options', (icon, event) => {
this.toggleDetailedForm();
// Update icon and tooltip
const iconEl = icon.querySelector('.icon');
if (iconEl) {
setIcon(iconEl as HTMLElement, this.isExpanded ? 'chevron-up' : 'chevron-down');
}
icon.setAttribute('title', this.isExpanded ? 'Hide detailed options' : 'Show detailed options');
});
// Add separator
const separator = this.actionBar.createDiv('action-separator');
separator.style.width = '1px';
separator.style.height = '24px';
separator.style.backgroundColor = 'var(--background-modifier-border)';
separator.style.margin = '0 var(--size-4-2)';
}
// Due date icon
this.createActionIcon(this.actionBar, 'calendar', 'Set due date', (icon, event) => {
this.showDateContextMenu(event, 'due');
}, 'due-date');
// Scheduled date icon
this.createActionIcon(this.actionBar, 'calendar-clock', 'Set scheduled date', (icon, event) => {
this.showDateContextMenu(event, 'scheduled');
}, 'scheduled-date');
// Status icon
this.createActionIcon(this.actionBar, 'dot-square', 'Set status', (icon, event) => {
this.showStatusContextMenu(event);
}, 'status');
// Priority icon
this.createActionIcon(this.actionBar, 'star', 'Set priority', (icon, event) => {
this.showPriorityContextMenu(event);
}, 'priority');
// Recurrence icon
this.createActionIcon(this.actionBar, 'refresh-ccw', 'Set recurrence', (icon, event) => {
this.showRecurrenceContextMenu(event);
}, 'recurrence');
// Update icon states based on current values
this.updateIconStates();
}
private parseAndFillForm(input: string): void {
const parsed = this.nlParser.parseInput(input);
this.applyParsedData(parsed);
// Expand the form to show filled fields
if (!this.isExpanded) {
this.expandModal();
}
}
private applyParsedData(parsed: NLParsedTaskData): void {
if (parsed.title) this.title = parsed.title;
if (parsed.status) this.status = parsed.status;
if (parsed.priority) this.priority = parsed.priority;
if (parsed.dueDate) this.dueDate = parsed.dueDate;
if (parsed.scheduledDate) this.scheduledDate = parsed.scheduledDate;
if (parsed.contexts) this.contexts = parsed.contexts.join(', ');
if (parsed.tags) this.tags = parsed.tags.join(', ');
if (parsed.details) this.details = parsed.details;
if (parsed.recurrence) this.recurrenceRule = parsed.recurrence;
// Update form inputs if they exist
if (this.titleInput) this.titleInput.value = this.title;
if (this.detailsInput) this.detailsInput.value = this.details;
// Update icon states
this.updateIconStates();
}
private toggleDetailedForm(): void {
if (this.isExpanded) {
// Collapse
this.isExpanded = false;
this.detailsContainer.style.display = 'none';
this.containerEl.removeClass('expanded');
} else {
// Expand
this.expandModal();
}
}
async initializeFormData(): Promise<void> {
// Initialize with default values from settings
this.priority = this.plugin.settings.defaultTaskPriority;
this.status = this.plugin.settings.defaultTaskStatus;
// Apply task creation defaults
const defaults = this.plugin.settings.taskCreationDefaults;
// Apply default due date
this.dueDate = calculateDefaultDate(defaults.defaultDueDate);
// Apply default scheduled date based on user settings
this.scheduledDate = calculateDefaultDate(defaults.defaultScheduledDate);
// Apply default contexts and tags
this.contexts = defaults.defaultContexts || '';
this.tags = defaults.defaultTags || '';
// Apply default time estimate
if (defaults.defaultTimeEstimate && defaults.defaultTimeEstimate > 0) {
this.timeEstimate = defaults.defaultTimeEstimate;
}
// Apply pre-populated values if provided (overrides defaults)
if (this.options.prePopulatedValues) {
this.applyPrePopulatedValues(this.options.prePopulatedValues);
}
}
private applyPrePopulatedValues(values: Partial<TaskInfo>): void {
if (values.title !== undefined) this.title = values.title;
if (values.due !== undefined) this.dueDate = values.due;
if (values.scheduled !== undefined) this.scheduledDate = values.scheduled;
if (values.priority !== undefined) this.priority = values.priority;
if (values.status !== undefined) this.status = values.status;
if (values.contexts !== undefined) {
this.contexts = values.contexts.join(', ');
}
if (values.tags !== undefined) {
this.tags = values.tags.filter(tag => tag !== this.plugin.settings.taskTag).join(', ');
}
if (values.timeEstimate !== undefined) this.timeEstimate = values.timeEstimate;
if (values.recurrence !== undefined && typeof values.recurrence === 'string') {
this.recurrenceRule = values.recurrence;
}
}
async handleSave(): Promise<void> {
// If NLP is enabled and there's content in the NL field, parse it first
if (this.plugin.settings.enableNaturalLanguageInput && this.nlInput) {
const nlContent = this.nlInput.value.trim();
if (nlContent && !this.title.trim()) {
// Only auto-parse if no title has been manually entered
const parsed = this.nlParser.parseInput(nlContent);
this.applyParsedData(parsed);
}
}
if (!this.validateForm()) {
new Notice('Please enter a task title');
return;
}
try {
const taskData = this.buildTaskData();
const result = await this.plugin.taskService.createTask(taskData);
new Notice(`Task "${result.taskInfo.title}" created successfully`);
if (this.options.onTaskCreated) {
this.options.onTaskCreated(result.taskInfo);
}
this.close();
} catch (error) {
console.error('Failed to create task:', error);
new Notice('Failed to create task: ' + error.message);
}
}
private buildTaskData(): Partial<TaskInfo> {
const now = getCurrentTimestamp();
// Parse contexts and tags
const contextList = this.contexts
.split(',')
.map(c => c.trim())
.filter(c => c.length > 0);
const tagList = this.tags
.split(',')
.map(t => t.trim())
.filter(t => t.length > 0);
// Add the task tag if it's not already present
if (this.plugin.settings.taskTag && !tagList.includes(this.plugin.settings.taskTag)) {
tagList.push(this.plugin.settings.taskTag);
}
const taskData: TaskCreationData = {
title: this.title.trim(),
due: this.dueDate || undefined,
scheduled: this.scheduledDate || undefined,
priority: this.priority,
status: this.status,
contexts: contextList.length > 0 ? contextList : undefined,
tags: tagList.length > 0 ? tagList : undefined,
timeEstimate: this.timeEstimate > 0 ? this.timeEstimate : undefined,
recurrence: this.recurrenceRule || undefined,
dateCreated: now,
dateModified: now
};
// Add details if provided
if (this.details.trim()) {
// You might want to add the details to the task content or as a separate field
// For now, we'll add it as part of the task description
taskData.details = this.details.trim();
}
return taskData;
}
private generateFilename(taskData: TaskCreationData): string {
const context: FilenameContext = {
title: taskData.title || '',
status: taskData.status || 'open',
priority: taskData.priority || 'normal',
dueDate: taskData.due,
scheduledDate: taskData.scheduled
};
return generateTaskFilename(context, this.plugin.settings);
}
// Override to prevent creating duplicate title input when NLP is enabled
protected createTitleInput(container: HTMLElement): void {
// Only create title input if NLP is disabled
if (!this.plugin.settings.enableNaturalLanguageInput) {
super.createTitleInput(container);
}
}
}

View file

@ -1,267 +0,0 @@
import { App, Notice } from 'obsidian';
import TaskNotesPlugin from '../main';
import { MinimalistTaskModal } from './MinimalistTaskModal';
import { TaskInfo } from '../types';
import { getCurrentTimestamp } from '../utils/dateUtils';
import { formatTimestampForDisplay } from '../utils/dateUtils';
export interface TaskEditOptions {
task: TaskInfo;
onTaskUpdated?: (task: TaskInfo) => void;
}
export class MinimalistTaskEditModal extends MinimalistTaskModal {
private task: TaskInfo;
private options: TaskEditOptions;
private metadataContainer: HTMLElement;
constructor(app: App, plugin: TaskNotesPlugin, options: TaskEditOptions) {
super(app, plugin);
this.task = options.task;
this.options = options;
}
getModalTitle(): string {
return 'Edit task';
}
async initializeFormData(): Promise<void> {
// Initialize form fields with current task data
this.title = this.task.title;
this.dueDate = this.task.due || '';
this.scheduledDate = this.task.scheduled || '';
this.priority = this.task.priority;
this.status = this.task.status;
this.contexts = this.task.contexts ? this.task.contexts.join(', ') : '';
this.tags = this.task.tags
? this.task.tags.filter(tag => tag !== this.plugin.settings.taskTag).join(', ')
: '';
this.timeEstimate = this.task.timeEstimate || 0;
// Handle recurrence - support both new rrule strings and old RecurrenceInfo objects
if (this.task.recurrence) {
if (typeof this.task.recurrence === 'string') {
this.recurrenceRule = this.task.recurrence;
} else if (typeof this.task.recurrence === 'object' && this.task.recurrence.frequency) {
// Legacy recurrence object - convert to string representation for display
this.recurrenceRule = this.convertLegacyRecurrenceToString(this.task.recurrence);
}
} else {
this.recurrenceRule = '';
}
}
private convertLegacyRecurrenceToString(recurrence: any): string {
// Convert legacy recurrence object to a readable string
// This is for display purposes in the edit modal
if (!recurrence.frequency) return '';
let recurrenceText = recurrence.frequency;
if (recurrence.frequency === 'weekly' && recurrence.days_of_week) {
recurrenceText += ` on ${recurrence.days_of_week.join(', ')}`;
}
if (recurrence.frequency === 'monthly' && recurrence.day_of_month) {
recurrenceText += ` on day ${recurrence.day_of_month}`;
}
return recurrenceText;
}
onOpen() {
this.containerEl.addClass('tasknotes-plugin', 'minimalist-task-modal');
this.titleEl.textContent = this.getModalTitle();
this.initializeFormData().then(() => {
this.createModalContent();
// Update icon states after creating the action bar
this.updateIconStates();
this.focusTitleInput();
});
}
protected createModalContent(): void {
const { contentEl } = this;
contentEl.empty();
// Create main container
const container = contentEl.createDiv('minimalist-modal-container');
// Create action bar with icons
this.createActionBar(container);
// Create expanded details section (always expanded for editing)
this.createDetailsSection(container);
// Create metadata section (for edit modal)
this.createMetadataSection(container);
// Create save/cancel buttons
this.createActionButtons(container);
}
private createMetadataSection(container: HTMLElement): void {
this.metadataContainer = container.createDiv('metadata-container');
const metadataLabel = this.metadataContainer.createDiv('detail-label');
metadataLabel.textContent = 'Task Information';
const metadataContent = this.metadataContainer.createDiv('metadata-content');
// Created date
if (this.task.dateCreated) {
const createdDiv = metadataContent.createDiv('metadata-item');
createdDiv.createSpan('metadata-key').textContent = 'Created: ';
createdDiv.createSpan('metadata-value').textContent = formatTimestampForDisplay(this.task.dateCreated);
}
// Modified date
if (this.task.dateModified) {
const modifiedDiv = metadataContent.createDiv('metadata-item');
modifiedDiv.createSpan('metadata-key').textContent = 'Modified: ';
modifiedDiv.createSpan('metadata-value').textContent = formatTimestampForDisplay(this.task.dateModified);
}
// File path (if available)
if (this.task.path) {
const pathDiv = metadataContent.createDiv('metadata-item');
pathDiv.createSpan('metadata-key').textContent = 'File: ';
pathDiv.createSpan('metadata-value').textContent = this.task.path;
}
}
async handleSave(): Promise<void> {
if (!this.validateForm()) {
new Notice('Please enter a task title');
return;
}
try {
const changes = this.getChanges();
if (Object.keys(changes).length === 0) {
new Notice('No changes to save');
this.close();
return;
}
const updatedTask = await this.plugin.taskService.updateTask(this.task, changes);
new Notice(`Task "${updatedTask.title}" updated successfully`);
if (this.options.onTaskUpdated) {
this.options.onTaskUpdated(updatedTask);
}
} catch (error) {
console.error('Failed to update task:', error);
new Notice('Failed to update task: ' + error.message);
}
}
private getChanges(): Partial<TaskInfo> {
const changes: Partial<TaskInfo> = {};
// Check for changes and only include modified fields
if (this.title.trim() !== this.task.title) {
changes.title = this.title.trim();
}
if (this.dueDate !== (this.task.due || '')) {
changes.due = this.dueDate || undefined;
}
if (this.scheduledDate !== (this.task.scheduled || '')) {
changes.scheduled = this.scheduledDate || undefined;
}
if (this.priority !== this.task.priority) {
changes.priority = this.priority;
}
if (this.status !== this.task.status) {
changes.status = this.status;
}
// Parse and compare contexts
const newContexts = this.contexts
.split(',')
.map(c => c.trim())
.filter(c => c.length > 0);
const oldContexts = this.task.contexts || [];
if (JSON.stringify(newContexts.sort()) !== JSON.stringify(oldContexts.sort())) {
changes.contexts = newContexts.length > 0 ? newContexts : undefined;
}
// Parse and compare tags
const newTags = this.tags
.split(',')
.map(t => t.trim())
.filter(t => t.length > 0);
// Add the task tag if it's not already present
if (this.plugin.settings.taskTag && !newTags.includes(this.plugin.settings.taskTag)) {
newTags.push(this.plugin.settings.taskTag);
}
const oldTags = this.task.tags || [];
if (JSON.stringify(newTags.sort()) !== JSON.stringify(oldTags.sort())) {
changes.tags = newTags.length > 0 ? newTags : undefined;
}
// Compare time estimate
const newTimeEstimate = this.timeEstimate > 0 ? this.timeEstimate : undefined;
const oldTimeEstimate = this.task.timeEstimate;
if (newTimeEstimate !== oldTimeEstimate) {
changes.timeEstimate = newTimeEstimate;
}
// Compare recurrence
const oldRecurrence = typeof this.task.recurrence === 'string'
? this.task.recurrence
: '';
if (this.recurrenceRule !== oldRecurrence) {
changes.recurrence = this.recurrenceRule || undefined;
}
// Always update modified timestamp if there are changes
if (Object.keys(changes).length > 0) {
changes.dateModified = getCurrentTimestamp();
}
return changes;
}
private async openTaskNote(): Promise<void> {
try {
// Get the file from the task path
const file = this.app.vault.getAbstractFileByPath(this.task.path);
if (!file) {
new Notice(`Could not find task file: ${this.task.path}`);
return;
}
// Open the file in a new leaf
const leaf = this.app.workspace.getLeaf(true);
await leaf.openFile(file as any);
// Close the modal
this.close();
} catch (error) {
console.error('Failed to open task note:', error);
new Notice('Failed to open task note');
}
}
// Start expanded for edit modal - override parent property
protected isExpanded = true;
}

File diff suppressed because it is too large Load diff

View file

@ -1,379 +1,267 @@
import { App, Notice, TFile, Setting } from 'obsidian';
import { RRule } from 'rrule';
import { App, Notice } from 'obsidian';
import TaskNotesPlugin from '../main';
import { BaseTaskModal } from './BaseTaskModal';
import { TaskModal } from './TaskModal';
import { TaskInfo } from '../types';
import { getCurrentTimestamp } from '../utils/dateUtils';
import { formatTimestampForDisplay } from '../utils/dateUtils';
export class TaskEditModal extends BaseTaskModal {
export interface TaskEditOptions {
task: TaskInfo;
onTaskUpdated?: (task: TaskInfo) => void;
}
constructor(app: App, plugin: TaskNotesPlugin, task: TaskInfo) {
export class TaskEditModal extends TaskModal {
private task: TaskInfo;
private options: TaskEditOptions;
private metadataContainer: HTMLElement;
constructor(app: App, plugin: TaskNotesPlugin, options: TaskEditOptions) {
super(app, plugin);
this.task = task;
this.task = options.task;
this.options = options;
}
protected async initializeFormData(): Promise<void> {
// With native cache, task data is always current - no need to refetch
getModalTitle(): string {
return 'Edit task';
}
async initializeFormData(): Promise<void> {
// Initialize form fields with current task data
this.title = this.task.title;
// Initialize date and time components properly
this.dueDate = this.task.due || '';
this.scheduledDate = this.task.scheduled || '';
// Time components will be handled by the input fields automatically
this.priority = this.task.priority;
this.status = this.task.status;
this.contexts = this.task.contexts ? this.task.contexts.join(', ') : '';
this.tags = this.task.tags ? this.task.tags.filter(tag => tag !== this.plugin.settings.taskTag).join(', ') : '';
// Preserve the original time estimate value, ensuring it's not reset to 0
this.timeEstimate = this.task.timeEstimate !== undefined ? this.task.timeEstimate : 0;
this.tags = this.task.tags
? this.task.tags.filter(tag => tag !== this.plugin.settings.taskTag).join(', ')
: '';
this.timeEstimate = this.task.timeEstimate || 0;
// Handle recurrence - support both new rrule strings and old RecurrenceInfo objects
if (this.task.recurrence) {
if (typeof this.task.recurrence === 'string') {
// New rrule string format
this.recurrenceRule = this.task.recurrence;
this.parseRRuleString(this.task.recurrence);
} else if (typeof this.task.recurrence === 'object' && this.task.recurrence.frequency) {
// Legacy RecurrenceInfo object - convert to rrule
this.convertLegacyRecurrenceToRRule(this.task.recurrence);
// Legacy recurrence object - convert to string representation for display
this.recurrenceRule = this.convertLegacyRecurrenceToString(this.task.recurrence);
}
} else {
// No recurrence
this.frequencyMode = 'NONE';
this.recurrenceRule = '';
}
}
private convertLegacyRecurrenceToRRule(recurrence: any): void {
try {
// Map legacy frequency to new format
switch (recurrence.frequency) {
case 'daily':
this.frequencyMode = 'DAILY';
this.rruleInterval = 1;
break;
case 'weekly':
this.frequencyMode = 'WEEKLY';
this.rruleInterval = 1;
if (recurrence.days_of_week && recurrence.days_of_week.length > 0) {
// Convert legacy day abbreviations to RRule weekdays
const dayMap: Record<string, any> = {
'mon': RRule.MO,
'tue': RRule.TU,
'wed': RRule.WE,
'thu': RRule.TH,
'fri': RRule.FR,
'sat': RRule.SA,
'sun': RRule.SU
};
this.rruleByWeekday = recurrence.days_of_week
.map((day: string) => dayMap[day.toLowerCase()])
.filter((wd: any) => wd);
}
break;
case 'monthly':
this.frequencyMode = 'MONTHLY';
this.rruleInterval = 1;
this.monthlyMode = 'day';
if (recurrence.day_of_month) {
this.rruleByMonthday = [recurrence.day_of_month];
}
break;
case 'yearly':
this.frequencyMode = 'YEARLY';
this.rruleInterval = 1;
if (recurrence.month_of_year) {
this.rruleByMonth = [recurrence.month_of_year];
}
if (recurrence.day_of_month) {
this.rruleByMonthday = [recurrence.day_of_month];
}
break;
default:
this.frequencyMode = 'NONE';
return;
}
// Generate the rrule string from the converted data
this.recurrenceRule = this.generateRRuleString();
} catch (error) {
console.error('Error converting legacy recurrence to rrule:', error);
this.frequencyMode = 'NONE';
this.recurrenceRule = '';
private convertLegacyRecurrenceToString(recurrence: any): string {
// Convert legacy recurrence object to a readable string
// This is for display purposes in the edit modal
if (!recurrence.frequency) return '';
let recurrenceText = recurrence.frequency;
if (recurrence.frequency === 'weekly' && recurrence.days_of_week) {
recurrenceText += ` on ${recurrence.days_of_week.join(', ')}`;
}
if (recurrence.frequency === 'monthly' && recurrence.day_of_month) {
recurrenceText += ` on day ${recurrence.day_of_month}`;
}
return recurrenceText;
}
async onOpen() {
onOpen() {
this.containerEl.addClass('tasknotes-plugin', 'minimalist-task-modal');
this.titleEl.textContent = this.getModalTitle();
this.initializeFormData().then(() => {
this.createModalContent();
// Update icon states after creating the action bar
this.updateIconStates();
this.focusTitleInput();
});
}
protected createModalContent(): void {
const { contentEl } = this;
contentEl.addClass('tasknotes-plugin', 'task-edit-modal');
new Setting(contentEl)
.setName('Edit task')
.setHeading();
contentEl.empty();
// Initialize form data and cache autocomplete data
await this.initializeFormData();
this.existingContexts = await this.getExistingContexts();
this.existingTags = await this.getExistingTags();
// Create main container
const container = contentEl.createDiv('minimalist-modal-container');
// Title with character count
this.createFormGroup(contentEl, 'Title', (container) => {
this.createTitleInputWithCounter(container, 200);
// Auto-focus on the title field for immediate editing
const input = container.querySelector('input');
if (input) {
window.setTimeout(() => input.focus(), 50);
}
});
// Create action bar with icons
this.createActionBar(container);
// Due Date
this.createFormGroup(contentEl, 'Due date', (container) => {
this.createDueDateInput(container);
});
// Create expanded details section (always expanded for editing)
this.createDetailsSection(container);
// Scheduled Date
this.createFormGroup(contentEl, 'Scheduled date', (container) => {
this.createScheduledDateInput(container);
});
// Create metadata section (for edit modal)
this.createMetadataSection(container);
// Priority
this.createFormGroup(contentEl, 'Priority', (container) => {
this.createPriorityDropdown(container);
});
// Status
this.createFormGroup(contentEl, 'Status', (container) => {
this.createStatusDropdown(container);
});
// Contexts with autocomplete
this.createFormGroup(contentEl, 'Contexts', (container) => {
this.createAutocompleteInput(
container,
'contexts',
() => this.existingContexts,
(value) => { this.contexts = value; }
);
});
// Tags with autocomplete
this.createFormGroup(contentEl, 'Tags', (container) => {
this.createAutocompleteInput(
container,
'tags',
() => this.existingTags,
(value) => { this.tags = value; }
);
});
// Time Estimate
this.createFormGroup(contentEl, 'Time estimate', (container) => {
this.createTimeEstimateInput(container);
});
// Recurrence
this.createFormGroup(contentEl, 'Recurrence', (container) => {
this.createRRuleBuilder(container);
});
// Metadata footer
this.createMetadataFooter(contentEl);
// Action buttons
this.createActionButtons(contentEl);
// Keyboard shortcuts
contentEl.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.close();
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.saveTask();
}
});
// Create save/cancel buttons
this.createActionButtons(container);
}
protected createActionButtons(container: HTMLElement): void {
const buttonContainer = container.createDiv({ cls: 'modal-form__buttons' });
// Open Note button
const openButton = buttonContainer.createEl('button', {
text: 'Open note',
cls: 'modal-form__button modal-form__button--tertiary'
});
openButton.addEventListener('click', () => {
this.openNote();
});
// Save button
const saveButton = buttonContainer.createEl('button', {
text: 'Save',
cls: 'modal-form__button modal-form__button--primary'
});
saveButton.addEventListener('click', () => {
this.saveTask();
});
private createMetadataSection(container: HTMLElement): void {
this.metadataContainer = container.createDiv('metadata-container');
// Cancel button
const cancelButton = buttonContainer.createEl('button', {
text: 'Cancel',
cls: 'modal-form__button modal-form__button--secondary'
});
cancelButton.addEventListener('click', () => {
this.close();
});
}
protected async handleSubmit(): Promise<void> {
await this.saveTask();
}
private createMetadataFooter(container: HTMLElement): void {
const footer = container.createDiv({ cls: 'task-edit-modal__metadata' });
const metadataLabel = this.metadataContainer.createDiv('detail-label');
metadataLabel.textContent = 'Task Information';
const metadataContainer = footer.createDiv({ cls: 'task-edit-modal__metadata-container' });
const metadataContent = this.metadataContainer.createDiv('metadata-content');
// Created date
if (this.task.dateCreated) {
metadataContainer.createDiv({
cls: 'task-edit-modal__metadata-item',
text: `Created: ${formatTimestampForDisplay(this.task.dateCreated, 'MMM d, yyyy \'at\' h:mm a')}`
});
const createdDiv = metadataContent.createDiv('metadata-item');
createdDiv.createSpan('metadata-key').textContent = 'Created: ';
createdDiv.createSpan('metadata-value').textContent = formatTimestampForDisplay(this.task.dateCreated);
}
// Modified date
if (this.task.dateModified) {
metadataContainer.createDiv({
cls: 'task-edit-modal__metadata-item',
text: `Modified: ${formatTimestampForDisplay(this.task.dateModified, 'MMM d, yyyy \'at\' h:mm a')}`
});
const modifiedDiv = metadataContent.createDiv('metadata-item');
modifiedDiv.createSpan('metadata-key').textContent = 'Modified: ';
modifiedDiv.createSpan('metadata-value').textContent = formatTimestampForDisplay(this.task.dateModified);
}
// File path (if available)
if (this.task.path) {
const pathDiv = metadataContent.createDiv('metadata-item');
pathDiv.createSpan('metadata-key').textContent = 'File: ';
pathDiv.createSpan('metadata-value').textContent = this.task.path;
}
}
private openNote(): void {
const file = this.app.vault.getAbstractFileByPath(this.task.path);
if (file instanceof TFile) {
this.app.workspace.getLeaf(false).openFile(file);
this.close();
}
}
async saveTask() {
// Validate required fields
if (!this.title || !this.title.trim()) {
new Notice('Title is required');
async handleSave(): Promise<void> {
if (!this.validateForm()) {
new Notice('Please enter a task title');
return;
}
if (this.title.length > 200) {
new Notice('Title is too long (max 200 characters)');
return;
}
// Validate recurrence fields
if (this.frequencyMode === 'WEEKLY' && this.rruleByWeekday.length === 0) {
new Notice('Please select at least one day for weekly recurrence');
return;
}
if (this.frequencyMode === 'MONTHLY') {
if (this.monthlyMode === 'day' && this.rruleByMonthday.length === 0) {
new Notice('Please specify a day for monthly recurrence');
return;
}
if (this.monthlyMode === 'weekday' && (this.rruleByWeekday.length === 0 || this.rruleBySetpos.length === 0)) {
new Notice('Please specify both position and weekday for monthly recurrence');
return;
}
}
if (this.frequencyMode === 'YEARLY') {
if (this.rruleByMonth.length === 0 || this.rruleByMonthday.length === 0) {
new Notice('Please specify both month and day for yearly recurrence');
return;
}
}
try {
// Prepare contexts and tags arrays
const contextsArray = this.contexts ? this.contexts.split(',').map(c => c.trim()).filter(c => c) : [];
const tagsArray = this.tags ? this.tags.split(',').map(t => t.trim()).filter(t => t) : [];
const changes = this.getChanges();
// Add task tag if not present
if (!tagsArray.includes(this.plugin.settings.taskTag)) {
tagsArray.unshift(this.plugin.settings.taskTag);
}
// Detect changes by comparing current form values with original task
const updates: Partial<TaskInfo> = {};
// Check for changes in simple fields
if (this.title !== this.task.title) {
updates.title = this.title;
}
if (this.priority !== this.task.priority) {
updates.priority = this.priority;
}
if (this.status !== this.task.status) {
updates.status = this.status;
}
// Check for changes in date fields (compare full datetime values)
const currentDueDate = this.dueDate || '';
const originalDueDate = this.task.due || '';
if (currentDueDate !== originalDueDate) {
updates.due = this.dueDate || undefined;
}
const currentScheduledDate = this.scheduledDate || '';
const originalScheduledDate = this.task.scheduled || '';
if (currentScheduledDate !== originalScheduledDate) {
updates.scheduled = this.scheduledDate || undefined;
}
// Check time estimate with proper handling of undefined vs 0
const originalTimeEstimate = this.task.timeEstimate !== undefined ? this.task.timeEstimate : 0;
if (this.timeEstimate !== originalTimeEstimate) {
updates.timeEstimate = this.timeEstimate > 0 ? this.timeEstimate : undefined;
}
// Check for changes in contexts array
const originalContexts = this.task.contexts || [];
const arraysEqual = (a: string[], b: string[]) => a.length === b.length && a.every((val, index) => val === b[index]);
if (!arraysEqual(contextsArray, originalContexts)) {
updates.contexts = contextsArray.length > 0 ? contextsArray : undefined;
}
// Check for changes in tags array
const originalTags = this.task.tags || [];
if (!arraysEqual(tagsArray, originalTags)) {
updates.tags = tagsArray;
}
// Check for changes in recurrence
const currentRecurrenceRule = this.recurrenceRule || '';
const originalRecurrenceRule = typeof this.task.recurrence === 'string'
? this.task.recurrence
: '';
if (currentRecurrenceRule !== originalRecurrenceRule) {
updates.recurrence = currentRecurrenceRule || undefined;
}
// If no changes detected, show message and return
if (Object.keys(updates).length === 0) {
new Notice('No changes detected');
if (Object.keys(changes).length === 0) {
new Notice('No changes to save');
this.close();
return;
}
// Call the centralized update service with current task state
await this.plugin.taskService.updateTask(this.task, updates);
const updatedTask = await this.plugin.taskService.updateTask(this.task, changes);
new Notice('Task updated successfully');
this.close();
new Notice(`Task "${updatedTask.title}" updated successfully`);
if (this.options.onTaskUpdated) {
this.options.onTaskUpdated(updatedTask);
}
} catch (error) {
console.error('Failed to save task:', error);
new Notice('Failed to save task. Please try again.');
console.error('Failed to update task:', error);
new Notice('Failed to update task: ' + error.message);
}
}
private getChanges(): Partial<TaskInfo> {
const changes: Partial<TaskInfo> = {};
// Check for changes and only include modified fields
if (this.title.trim() !== this.task.title) {
changes.title = this.title.trim();
}
if (this.dueDate !== (this.task.due || '')) {
changes.due = this.dueDate || undefined;
}
if (this.scheduledDate !== (this.task.scheduled || '')) {
changes.scheduled = this.scheduledDate || undefined;
}
if (this.priority !== this.task.priority) {
changes.priority = this.priority;
}
if (this.status !== this.task.status) {
changes.status = this.status;
}
// Parse and compare contexts
const newContexts = this.contexts
.split(',')
.map(c => c.trim())
.filter(c => c.length > 0);
const oldContexts = this.task.contexts || [];
if (JSON.stringify(newContexts.sort()) !== JSON.stringify(oldContexts.sort())) {
changes.contexts = newContexts.length > 0 ? newContexts : undefined;
}
// Parse and compare tags
const newTags = this.tags
.split(',')
.map(t => t.trim())
.filter(t => t.length > 0);
// Add the task tag if it's not already present
if (this.plugin.settings.taskTag && !newTags.includes(this.plugin.settings.taskTag)) {
newTags.push(this.plugin.settings.taskTag);
}
const oldTags = this.task.tags || [];
if (JSON.stringify(newTags.sort()) !== JSON.stringify(oldTags.sort())) {
changes.tags = newTags.length > 0 ? newTags : undefined;
}
// Compare time estimate
const newTimeEstimate = this.timeEstimate > 0 ? this.timeEstimate : undefined;
const oldTimeEstimate = this.task.timeEstimate;
if (newTimeEstimate !== oldTimeEstimate) {
changes.timeEstimate = newTimeEstimate;
}
// Compare recurrence
const oldRecurrence = typeof this.task.recurrence === 'string'
? this.task.recurrence
: '';
if (this.recurrenceRule !== oldRecurrence) {
changes.recurrence = this.recurrenceRule || undefined;
}
// Always update modified timestamp if there are changes
if (Object.keys(changes).length > 0) {
changes.dateModified = getCurrentTimestamp();
}
return changes;
}
private async openTaskNote(): Promise<void> {
try {
// Get the file from the task path
const file = this.app.vault.getAbstractFileByPath(this.task.path);
if (!file) {
new Notice(`Could not find task file: ${this.task.path}`);
return;
}
// Open the file in a new leaf
const leaf = this.app.workspace.getLeaf(true);
await leaf.openFile(file as any);
// Close the modal
this.close();
} catch (error) {
console.error('Failed to open task note:', error);
new Notice('Failed to open task note');
}
}
// Start expanded for edit modal - override parent property
protected isExpanded = true;
}

View file

@ -6,7 +6,7 @@ import { StatusContextMenu } from '../components/StatusContextMenu';
import { RecurrenceContextMenu } from '../components/RecurrenceContextMenu';
import { getDatePart, getTimePart, combineDateAndTime } from '../utils/dateUtils';
export abstract class MinimalistTaskModal extends Modal {
export abstract class TaskModal extends Modal {
plugin: TaskNotesPlugin;
// Core task properties

View file

@ -0,0 +1,16 @@
import { Editor } from 'obsidian';
import { ParsedTaskData } from '../utils/TasksPluginParser';
export interface TaskConversionOptions {
parsedData?: ParsedTaskData;
editor?: Editor;
lineNumber?: number;
selectionInfo?: {
taskLine: string;
details: string;
startLine: number;
endLine: number;
originalContent: string[]
};
prefilledDetails?: string;
}

View file

@ -25,8 +25,8 @@ import {
CalendarViewPreferences,
ICSEvent
} from '../types';
import { MinimalistTaskCreationModal } from '../modals/MinimalistTaskCreationModal';
import { MinimalistTaskEditModal } from '../modals/MinimalistTaskEditModal';
import { TaskCreationModal } from '../modals/TaskCreationModal';
import { TaskEditModal } from '../modals/TaskEditModal';
import { UnscheduledTasksSelectorModal, ScheduleTaskOptions } from '../modals/UnscheduledTasksSelectorModal';
import { TimeblockCreationModal } from '../modals/TimeblockCreationModal';
import { FilterBar } from '../ui/FilterBar';
@ -827,7 +827,7 @@ export class AdvancedCalendarView extends ItemView {
? 60 // Default 1 hour for all-day events
: Math.round((end.getTime() - start.getTime()) / (1000 * 60)); // Duration in minutes
const modal = new MinimalistTaskCreationModal(this.app, this.plugin, {
const modal = new TaskCreationModal(this.app, this.plugin, {
prePopulatedValues: {
scheduled: scheduledDate,
timeEstimate: timeEstimate > 0 ? timeEstimate : 60
@ -979,7 +979,7 @@ export class AdvancedCalendarView extends ItemView {
}
} else if (jsEvent.button === 0) {
// Left click only: Open edit modal
const editModal = new MinimalistTaskEditModal(this.app, this.plugin, { task: taskInfo });
const editModal = new TaskEditModal(this.app, this.plugin, { task: taskInfo });
editModal.open();
}
}

View file

@ -117,8 +117,6 @@
BASE MODAL CONTAINER STYLES
===================================================================== */
.tasknotes-plugin .task-creation-modal,
.tasknotes-plugin .task-edit-modal,
.tasknotes-plugin .due-date-modal,
.tasknotes-plugin .scheduled-date-modal,
.tasknotes-plugin .task-selector-modal {
@ -134,8 +132,6 @@
MODAL HEADERS AND HEADINGS
===================================================================== */
.tasknotes-plugin .task-creation-modal .setting-item-heading,
.tasknotes-plugin .task-edit-modal .setting-item-heading,
.tasknotes-plugin .due-date-modal .setting-item-heading,
.tasknotes-plugin .scheduled-date-modal .setting-item-heading {
font-size: var(--tn-font-size-xl);
@ -665,62 +661,8 @@
color: var(--tn-text-normal);
}
/* =====================================================================
TASK CREATION MODAL SPECIFIC
===================================================================== */
.tasknotes-plugin .task-creation-modal__preview {
padding: var(--tn-spacing-md) var(--tn-spacing-lg);
background: var(--tn-bg-secondary);
border: 1px solid var(--tn-border-color);
border-radius: var(--tn-radius-sm);
font-size: var(--tn-font-size-sm);
font-family: var(--font-monospace);
color: var(--tn-text-muted);
margin-top: var(--tn-spacing-md);
word-break: break-all;
}
.tasknotes-plugin .task-creation-modal__preview--valid {
background: rgba(var(--tn-interactive-success), 0.1);
border-color: var(--tn-interactive-success);
color: var(--tn-text-normal);
}
.tasknotes-plugin .task-creation-modal__preview--error {
background: rgba(var(--tn-color-error), 0.1);
border-color: var(--tn-color-error);
color: var(--tn-color-error);
}
/* =====================================================================
TASK EDIT MODAL SPECIFIC
===================================================================== */
.tasknotes-plugin .task-edit-modal__metadata {
margin-top: var(--tn-spacing-xxl);
padding-top: var(--tn-spacing-lg);
border-top: 1px solid var(--tn-border-color);
}
.tasknotes-plugin .task-edit-modal__metadata-container {
display: flex;
flex-direction: column;
gap: var(--tn-spacing-md);
}
.tasknotes-plugin .task-edit-modal__metadata-item {
font-size: var(--tn-font-size-sm);
color: var(--tn-text-muted);
line-height: 1.4;
padding: var(--tn-spacing-md) var(--tn-spacing-lg);
background: var(--tn-bg-secondary);
border: 1px solid var(--tn-border-color);
border-radius: var(--tn-radius-sm);
font-weight: var(--tn-font-weight-normal);
}
/* =====================================================================
QUICK DATE MODALS SPECIFIC
===================================================================== */
@ -844,8 +786,6 @@
===================================================================== */
@media (max-width: 768px) {
.tasknotes-plugin .task-creation-modal,
.tasknotes-plugin .task-edit-modal,
.tasknotes-plugin .due-date-modal,
.tasknotes-plugin .scheduled-date-modal {
padding: var(--tn-spacing-lg);

View file

@ -1,5 +1,5 @@
/* =====================================================================
MINIMALIST TASK MODAL - Google Keep/Todoist Inspired
TASK MODAL - Google Keep/Todoist Inspired
===================================================================== */
.tasknotes-plugin .minimalist-task-modal {

View file

@ -20,7 +20,7 @@ This document provides a comprehensive analysis of the test suite implementation
- ✅ **TaskCard.test.ts** - Task card rendering, interactions, status updates
#### Modals (`tests/unit/modals/`)
- ✅ **BaseTaskModal.test.ts** - Base modal functionality, form utilities, RRule handling
- ✅ **TaskModal.test.ts** - Base modal functionality, form utilities, RRule handling
- ✅ **TaskCreationModal.test.ts** - Task creation, validation, natural language processing
### 2. Integration Tests (`tests/integration/`)

View file

@ -1,757 +0,0 @@
/**
* BaseTaskModal Tests
*
* Tests for the BaseTaskModal base class including:
* - Form creation and management utilities
* - RRule parsing and generation
* - Date/time input handling
* - Autocomplete functionality
* - Validation helpers
* - Character counting utilities
* - Form state management
* - Error handling and edge cases
*/
import { BaseTaskModal } from '../../../src/modals/BaseTaskModal';
import { RRule, Frequency } from 'rrule';
import { MockObsidian } from '../../__mocks__/obsidian';
import type { App } from 'obsidian';
// Type helper to safely cast mock App to real App type
const createMockApp = (mockApp: any): App => mockApp as unknown as App;
// Mock external dependencies
jest.mock('obsidian');
jest.mock('rrule');
// Mock date utils
jest.mock('../../../src/utils/dateUtils', () => ({
normalizeDateString: jest.fn((date) => date?.split('T')[0] || date),
validateDateInput: jest.fn(() => true),
hasTimeComponent: jest.fn((date) => date?.includes('T')),
getDatePart: jest.fn((date) => date?.split('T')[0] || date),
getTimePart: jest.fn((date) => date?.includes('T') ? '10:00' : null),
combineDateAndTime: jest.fn((date, time) => time ? `${date}T${time}` : date),
validateDateTimeInput: jest.fn(() => true)
}));
// Concrete implementation of BaseTaskModal for testing
class TestTaskModal extends BaseTaskModal {
protected async initializeFormData(): Promise<void> {
this.title = 'Test Task';
this.priority = 'normal';
this.status = 'open';
}
protected createActionButtons(container: HTMLElement): void {
const button = container.createEl('button', { text: 'Test Button' });
button.addEventListener('click', () => this.handleSubmit());
}
protected async handleSubmit(): Promise<void> {
// Test implementation
}
}
describe('BaseTaskModal', () => {
let mockApp: App;
let mockPlugin: any;
let modal: TestTaskModal;
let container: HTMLElement;
beforeEach(() => {
jest.clearAllMocks();
MockObsidian.reset();
// Mock RRule constructor and toString to work properly with instance data
const mockRRule = jest.requireActual('../../../tests/__mocks__/rrule.ts');
// Store original static methods before overriding
const originalRRule = RRule;
const originalFromString = RRule.fromString;
// Mock the constructor to properly set instance data
(RRule as any) = jest.fn().mockImplementation(function(this: any, options: any = {}) {
this.options = options;
return this;
});
// Restore static methods
(RRule as any).fromString = originalFromString;
Object.assign(RRule, originalRRule);
// Set up the prototype with proper toString method
(RRule as any).prototype.toString = function(this: any) {
// Manually implement the toString logic
if (!this.options) {
return 'FREQ=DAILY;INTERVAL=1';
}
const { freq, interval = 1, byweekday, bymonthday, bymonth, bysetpos, until, count } = this.options;
let result = '';
const Frequency = mockRRule.Frequency;
switch (freq) {
case Frequency.DAILY:
result = 'FREQ=DAILY';
break;
case Frequency.WEEKLY:
result = 'FREQ=WEEKLY';
break;
case Frequency.MONTHLY:
result = 'FREQ=MONTHLY';
break;
case Frequency.YEARLY:
result = 'FREQ=YEARLY';
break;
default:
return 'FREQ=DAILY;INTERVAL=1';
}
if (interval && interval > 1) {
result += `;INTERVAL=${interval}`;
}
if (byweekday && byweekday.length > 0) {
const dayMap = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
const days = byweekday.map((w: any) => dayMap[w.weekday || w]).filter(Boolean);
if (days.length > 0) {
result += `;BYDAY=${days.join(',')}`;
}
}
if (bymonthday && bymonthday.length > 0) {
result += `;BYMONTHDAY=${bymonthday.join(',')}`;
}
if (bymonth && bymonth.length > 0) {
result += `;BYMONTH=${bymonth.join(',')}`;
}
if (bysetpos && bysetpos.length > 0) {
result += `;BYSETPOS=${bysetpos.join(',')}`;
}
if (until) {
const dateStr = until.toISOString().split('T')[0].replace(/-/g, '');
result += `;UNTIL=${dateStr}`;
}
if (count) {
result += `;COUNT=${count}`;
}
return result;
};
// Create DOM container
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
// Mock app
mockApp = createMockApp(MockObsidian.createMockApp());
// Mock plugin
mockPlugin = {
app: mockApp,
settings: {
taskTag: 'task',
customStatuses: [
{ value: 'open', label: 'Open' },
{ value: 'done', label: 'Done' }
],
customPriorities: [
{ value: 'normal', label: 'Normal' },
{ value: 'high', label: 'High' }
]
},
cacheManager: {
getAllContexts: jest.fn().mockReturnValue(['work', 'home', 'urgent']),
getAllTags: jest.fn().mockReturnValue(['task', 'important', 'review'])
}
};
// @ts-ignore: Mock App type is compatible at runtime despite TypeScript warnings
modal = new TestTaskModal(createMockApp(mockApp), mockPlugin);
// Mock console methods
jest.spyOn(console, 'error').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'debug').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
document.body.innerHTML = '';
if (modal) {
modal.close();
}
});
describe('Modal Initialization', () => {
it('should initialize with default properties', () => {
expect(modal.title).toBe('');
expect(modal.dueDate).toBe('');
expect(modal.scheduledDate).toBe('');
expect(modal.priority).toBe('normal');
expect(modal.status).toBe('open');
expect(modal.contexts).toBe('');
expect(modal.tags).toBe('');
expect(modal.timeEstimate).toBe(0);
expect((modal as any).frequencyMode).toBe('NONE');
});
it('should initialize form data when called', async () => {
await (modal as any).initializeFormData();
expect(modal.title).toBe('Test Task');
expect(modal.priority).toBe('normal');
expect(modal.status).toBe('open');
});
});
describe('Cache Management', () => {
it('should get existing contexts', async () => {
const contexts = await modal.getExistingContexts();
expect(mockPlugin.cacheManager.getAllContexts).toHaveBeenCalled();
expect(contexts).toEqual(['work', 'home', 'urgent']);
});
it('should get existing tags excluding task tag', async () => {
const tags = await modal.getExistingTags();
expect(mockPlugin.cacheManager.getAllTags).toHaveBeenCalled();
expect(tags).toEqual(['important', 'review']); // 'task' filtered out
});
});
describe('Day Name Conversion', () => {
it('should convert abbreviations to full names', () => {
const abbrs = ['mon', 'tue', 'fri'];
const fullNames = (modal as any).convertAbbreviationsToFullNames(abbrs);
expect(fullNames).toEqual(['Monday', 'Tuesday', 'Friday']);
});
it('should convert full names to abbreviations', () => {
const fullNames = ['Monday', 'Tuesday', 'Friday'];
const abbrs = (modal as any).convertFullNamesToAbbreviations(fullNames);
expect(abbrs).toEqual(['mon', 'tue', 'fri']);
});
it('should filter out invalid day names', () => {
const invalid = ['invalid', 'mon', 'not-a-day'];
const fullNames = (modal as any).convertAbbreviationsToFullNames(invalid);
expect(fullNames).toEqual(['Monday']);
});
});
describe('RRule Parsing', () => {
it('should parse daily recurrence rule', () => {
const rruleString = 'FREQ=DAILY;INTERVAL=2';
(modal as any).parseRRuleString(rruleString);
expect((modal as any).frequencyMode).toBe('DAILY');
expect((modal as any).rruleInterval).toBe(2);
});
it('should parse weekly recurrence rule', () => {
const rruleString = 'FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR';
(modal as any).parseRRuleString(rruleString);
expect((modal as any).frequencyMode).toBe('WEEKLY');
expect((modal as any).rruleInterval).toBe(1);
expect((modal as any).rruleByWeekday).toHaveLength(3);
});
it('should parse monthly recurrence rule', () => {
const rruleString = 'FREQ=MONTHLY;BYMONTHDAY=15';
(modal as any).parseRRuleString(rruleString);
expect((modal as any).frequencyMode).toBe('MONTHLY');
expect((modal as any).monthlyMode).toBe('day');
expect((modal as any).rruleByMonthday).toEqual([15]);
});
it('should parse yearly recurrence rule', () => {
const rruleString = 'FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=15';
(modal as any).parseRRuleString(rruleString);
expect((modal as any).frequencyMode).toBe('YEARLY');
expect((modal as any).rruleByMonth).toEqual([6]);
expect((modal as any).rruleByMonthday).toEqual([15]);
});
it('should parse recurrence with end date', () => {
const until = new Date('2025-12-31');
const rruleString = `FREQ=DAILY;UNTIL=${until.toISOString().split('T')[0].replace(/-/g, '')}`;
(modal as any).parseRRuleString(rruleString);
expect((modal as any).endMode).toBe('until');
expect((modal as any).rruleUntil).toBeInstanceOf(Date);
});
it('should parse recurrence with count', () => {
const rruleString = 'FREQ=DAILY;COUNT=10';
(modal as any).parseRRuleString(rruleString);
expect((modal as any).endMode).toBe('count');
expect((modal as any).rruleCount).toBe(10);
});
it('should handle empty rrule string', () => {
(modal as any).parseRRuleString('');
expect((modal as any).frequencyMode).toBe('NONE');
expect((modal as any).rruleInterval).toBe(1);
});
it('should handle invalid rrule string', () => {
(modal as any).parseRRuleString('INVALID_RRULE');
expect((modal as any).frequencyMode).toBe('NONE');
expect(console.error).toHaveBeenCalled();
});
it('should determine monthly weekday mode', () => {
const rruleString = 'FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1';
(modal as any).parseRRuleString(rruleString);
expect((modal as any).frequencyMode).toBe('MONTHLY');
expect((modal as any).monthlyMode).toBe('weekday');
});
});
describe('RRule Generation', () => {
it('should generate daily recurrence rule', () => {
(modal as any).frequencyMode = 'DAILY';
(modal as any).rruleInterval = 3;
const rruleString = (modal as any).generateRRuleString();
expect(rruleString).toContain('FREQ=DAILY');
expect(rruleString).toContain('INTERVAL=3');
});
it('should generate weekly recurrence rule', () => {
(modal as any).frequencyMode = 'WEEKLY';
(modal as any).rruleByWeekday = [{ weekday: 0 }, { weekday: 2 }]; // Monday, Wednesday
const rruleString = (modal as any).generateRRuleString();
expect(rruleString).toContain('FREQ=WEEKLY');
});
it('should generate monthly day recurrence rule', () => {
(modal as any).frequencyMode = 'MONTHLY';
(modal as any).monthlyMode = 'day';
(modal as any).rruleByMonthday = [15];
const rruleString = (modal as any).generateRRuleString();
expect(rruleString).toContain('FREQ=MONTHLY');
});
it('should generate monthly weekday recurrence rule', () => {
(modal as any).frequencyMode = 'MONTHLY';
(modal as any).monthlyMode = 'weekday';
(modal as any).rruleByWeekday = [{ weekday: 0 }];
(modal as any).rruleBySetpos = [1];
const rruleString = (modal as any).generateRRuleString();
expect(rruleString).toContain('FREQ=MONTHLY');
});
it('should generate yearly recurrence rule', () => {
(modal as any).frequencyMode = 'YEARLY';
(modal as any).rruleByMonth = [6];
(modal as any).rruleByMonthday = [15];
const rruleString = (modal as any).generateRRuleString();
expect(rruleString).toContain('FREQ=YEARLY');
});
it('should generate rule with until date', () => {
(modal as any).frequencyMode = 'DAILY';
(modal as any).endMode = 'until';
(modal as any).rruleUntil = new Date('2025-12-31');
const rruleString = (modal as any).generateRRuleString();
expect(rruleString).toContain('FREQ=DAILY');
});
it('should generate rule with count', () => {
(modal as any).frequencyMode = 'DAILY';
(modal as any).endMode = 'count';
(modal as any).rruleCount = 10;
const rruleString = (modal as any).generateRRuleString();
expect(rruleString).toContain('FREQ=DAILY');
});
it('should return empty string for NONE frequency', () => {
(modal as any).frequencyMode = 'NONE';
const rruleString = (modal as any).generateRRuleString();
expect(rruleString).toBe('');
});
it('should handle generation errors gracefully', () => {
// Store the original RRule to restore it after the test
const originalRRule = RRule;
// Override the working RRule mock to throw an error
(RRule as any) = jest.fn().mockImplementation(() => {
throw new Error('RRule creation failed');
});
(modal as any).frequencyMode = 'DAILY';
const rruleString = (modal as any).generateRRuleString();
expect(rruleString).toBe('');
expect(console.error).toHaveBeenCalled();
// Restore the original RRule for subsequent tests
(global as any).RRule = originalRRule;
Object.assign(RRule, originalRRule);
});
});
describe('RRule Human Text', () => {
it('should generate human text for valid rule', () => {
modal.recurrenceRule = 'FREQ=DAILY;INTERVAL=1';
const humanText = (modal as any).getRRuleHumanText();
expect(humanText).toBe('every day'); // Mocked response
});
it('should handle no recurrence rule', () => {
modal.recurrenceRule = '';
const humanText = (modal as any).getRRuleHumanText();
expect(humanText).toBe('No recurrence');
});
it('should handle invalid recurrence rule', () => {
modal.recurrenceRule = 'INVALID_RULE';
const humanText = (modal as any).getRRuleHumanText();
expect(humanText).toBe('Invalid recurrence rule');
expect(console.error).toHaveBeenCalled();
});
});
describe('Form Group Creation', () => {
it('should create form group with label and input container', () => {
const formGroup = (modal as any).createFormGroup(container, 'Test Label', (inputContainer: HTMLElement) => {
inputContainer.createEl('input', { type: 'text' });
});
expect(formGroup.classList.contains('modal-form__group')).toBe(true);
const label = formGroup.querySelector('.modal-form__label');
expect(label?.textContent).toBe('Test Label');
const inputContainer = formGroup.querySelector('.modal-form__input-container');
expect(inputContainer).toBeTruthy();
const input = inputContainer?.querySelector('input');
expect(input).toBeTruthy();
});
it('should create accessible form group with proper ARIA labels', () => {
const formGroup = (modal as any).createFormGroup(container, 'Accessible Label', (inputContainer: HTMLElement) => {
inputContainer.createEl('input', { type: 'text' });
});
const label = formGroup.querySelector('.modal-form__label');
const inputContainer = formGroup.querySelector('.modal-form__input-container');
expect(label?.id).toBeTruthy();
expect(inputContainer?.getAttribute('aria-labelledby')).toBe(label?.id);
});
});
describe('Autocomplete Input', () => {
beforeEach(() => {
(modal as any).existingContexts = ['work', 'home', 'urgent'];
(modal as any).existingTags = ['important', 'review'];
});
it('should create autocomplete input with proper attributes', async () => {
const getSuggestions = jest.fn().mockResolvedValue(['work', 'home']);
const onChange = jest.fn();
await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange);
const input = container.querySelector('input');
expect(input).toBeTruthy();
expect(input?.getAttribute('aria-autocomplete')).toBe('list');
expect(input?.getAttribute('role')).toBe('combobox');
expect(input?.getAttribute('aria-expanded')).toBe('false');
});
it('should update field value on input', async () => {
const getSuggestions = jest.fn().mockResolvedValue([]);
const onChange = jest.fn();
await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange);
const input = container.querySelector('input') as HTMLInputElement;
input.value = 'work, home';
input.dispatchEvent(new Event('input'));
expect(modal.contexts).toBe('work, home');
expect(onChange).toHaveBeenCalledWith('work, home');
});
it('should show suggestions on focus', async () => {
const getSuggestions = jest.fn().mockResolvedValue(['work', 'home', 'urgent']);
const onChange = jest.fn();
await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange);
const input = container.querySelector('input') as HTMLInputElement;
input.focus();
// Allow async operations to complete
await new Promise(resolve => setTimeout(resolve, 0));
expect(getSuggestions).toHaveBeenCalled();
});
it('should hide suggestions on blur', async () => {
const getSuggestions = jest.fn().mockResolvedValue(['work', 'home']);
const onChange = jest.fn();
await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange);
const input = container.querySelector('input') as HTMLInputElement;
input.focus();
input.blur();
// Allow timeout to complete
await new Promise(resolve => setTimeout(resolve, 250));
const suggestions = container.querySelector('.modal-form__suggestions');
expect(suggestions).toBeFalsy();
});
it('should handle keyboard navigation in suggestions', async () => {
const getSuggestions = jest.fn().mockResolvedValue(['work', 'home', 'urgent']);
const onChange = jest.fn();
await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange);
const input = container.querySelector('input') as HTMLInputElement;
// Mock suggestions being shown
const suggestionsList = container.createDiv({ cls: 'modal-form__suggestions' });
suggestionsList.createDiv({ cls: 'modal-form__suggestion', text: 'work' });
suggestionsList.createDiv({ cls: 'modal-form__suggestion', text: 'home' });
const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
input.dispatchEvent(arrowDownEvent);
// Should not throw errors
expect(true).toBe(true);
});
it('should fetch fresh data when suggestions are empty', async () => {
const getSuggestions = jest.fn().mockResolvedValue([]);
const onChange = jest.fn();
await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange);
const input = container.querySelector('input') as HTMLInputElement;
input.focus();
// Allow async operations to complete
await new Promise(resolve => setTimeout(resolve, 0));
expect(mockPlugin.cacheManager.getAllContexts).toHaveBeenCalled();
});
});
describe('Suggestion Management', () => {
it('should show suggestions list', () => {
const input = container.createEl('input');
const suggestions = ['work', 'home', 'urgent'];
const onChange = jest.fn();
(modal as any).showSuggestions(container, suggestions, input, onChange);
const suggestionsList = container.querySelector('.modal-form__suggestions');
expect(suggestionsList).toBeTruthy();
expect(suggestionsList?.getAttribute('role')).toBe('listbox');
expect(input.getAttribute('aria-expanded')).toBe('true');
});
it('should hide suggestions when list is empty', () => {
const input = container.createEl('input');
const suggestions: string[] = [];
const onChange = jest.fn();
(modal as any).showSuggestions(container, suggestions, input, onChange);
const suggestionsList = container.querySelector('.modal-form__suggestions');
expect(suggestionsList).toBeFalsy();
expect(input.getAttribute('aria-expanded')).toBe('false');
});
it('should hide existing suggestions before showing new ones', () => {
const input = container.createEl('input');
// Create existing suggestions
container.createDiv({ cls: 'modal-form__suggestions' });
const suggestions = ['work', 'home'];
const onChange = jest.fn();
(modal as any).showSuggestions(container, suggestions, input, onChange);
const suggestionsLists = container.querySelectorAll('.modal-form__suggestions');
expect(suggestionsLists.length).toBe(1); // Should only have one list
});
it('should filter suggestions based on current input', () => {
const input = container.createEl('input') as HTMLInputElement;
input.value = 'work, ho'; // Partial input
const suggestions = ['work', 'home', 'urgent'];
const onChange = jest.fn();
(modal as any).showSuggestions(container, suggestions, input, onChange);
// Should show suggestions (exact filtering logic is in full implementation)
const suggestionsList = container.querySelector('.modal-form__suggestions');
expect(suggestionsList).toBeTruthy();
});
it('should handle suggestion selection', () => {
const input = container.createEl('input') as HTMLInputElement;
input.value = 'work, ';
const onChange = jest.fn();
(modal as any).applySuggestion(input, 'home', onChange);
expect(input.value).toBe('work, home');
expect(onChange).toHaveBeenCalledWith('work, home');
});
});
describe('Error Handling', () => {
it('should handle errors in RRule parsing gracefully', () => {
// Mock RRule.fromString to throw error for this test
const originalFromString = RRule.fromString;
RRule.fromString = jest.fn().mockImplementationOnce(() => {
throw new Error('Invalid RRule');
});
(modal as any).parseRRuleString('INVALID_RULE');
expect((modal as any).frequencyMode).toBe('NONE');
expect(console.error).toHaveBeenCalled();
// Restore original fromString
RRule.fromString = originalFromString;
});
it('should handle errors in suggestion fetching', async () => {
mockPlugin.cacheManager.getAllContexts.mockImplementation(() => {
throw new Error('Cache error');
});
const contexts = await modal.getExistingContexts();
// Should not throw, might return empty array or cached data
expect(Array.isArray(contexts)).toBe(true);
});
it('should handle malformed autocomplete input', async () => {
const getSuggestions = jest.fn().mockRejectedValue(new Error('Fetch error'));
const onChange = jest.fn();
await expect(
(modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange)
).resolves.not.toThrow();
});
});
describe('State Management', () => {
it('should reset RRule properties correctly', () => {
// Set some properties first
(modal as any).frequencyMode = 'WEEKLY';
(modal as any).rruleInterval = 2;
(modal as any).rruleByWeekday = [{ weekday: 0 }];
(modal as any).endMode = 'count';
(modal as any).rruleCount = 10;
(modal as any).resetRRuleProperties();
expect((modal as any).frequencyMode).toBe('NONE');
expect((modal as any).rruleInterval).toBe(1);
expect((modal as any).rruleByWeekday).toEqual([]);
expect((modal as any).endMode).toBe('never');
expect((modal as any).rruleCount).toBeNull();
});
it('should maintain form field state correctly', () => {
modal.title = 'Updated Task';
modal.priority = 'high';
modal.contexts = 'work, urgent';
expect(modal.title).toBe('Updated Task');
expect(modal.priority).toBe('high');
expect(modal.contexts).toBe('work, urgent');
});
});
describe('Performance and Memory', () => {
it('should handle multiple rapid suggestion requests', async () => {
const getSuggestions = jest.fn().mockResolvedValue(['work', 'home', 'urgent']);
const onChange = jest.fn();
await (modal as any).createAutocompleteInput(container, 'contexts', getSuggestions, onChange);
const input = container.querySelector('input') as HTMLInputElement;
// Rapid focus/blur cycles
for (let i = 0; i < 10; i++) {
input.focus();
input.blur();
}
// Should not cause memory leaks or errors
expect(true).toBe(true);
});
it('should handle large suggestion lists efficiently', () => {
const largeSuggestions = Array.from({ length: 1000 }, (_, i) => `item-${i}`);
const input = container.createEl('input');
const onChange = jest.fn();
const startTime = Date.now();
(modal as any).showSuggestions(container, largeSuggestions, input, onChange);
const endTime = Date.now();
expect(endTime - startTime).toBeLessThan(100); // Should be fast
const suggestionsList = container.querySelector('.modal-form__suggestions');
expect(suggestionsList).toBeTruthy();
});
});
});

View file

@ -5,7 +5,8 @@
* Jest interference issues and provide robust, reliable tests.
*/
import { TaskCreationModal, TaskConversionOptions } from '../../../src/modals/TaskCreationModal';
import { TaskCreationModal } from '../../../src/modals/TaskCreationModal';
import { TaskConversionOptions } from '../../../src/types/taskConversion';
import { TaskInfo } from '../../../src/types';
import { ParsedTaskData } from '../../../src/utils/TasksPluginParser';
import { MockObsidian, Notice, TFile } from '../../__mocks__/obsidian';